""" Sprint 42 Tests: SessionDB injection into AIAgent for WebUI sessions (PR #356). Covers: - streaming.py: SessionDB is initialized inside _run_agent_streaming (import present) - streaming.py: try/except guards SessionDB init so failures are non-fatal - streaming.py: session_db= kwarg is passed to AIAgent constructor - streaming.py: SessionDB init failure prints a WARNING (not silently swallowed) - streaming.py: SessionDB init is placed before AIAgent construction """ import ast import threading import pathlib import re import queue import sys import types import unittest from unittest import mock REPO_ROOT = pathlib.Path(__file__).parent.parent STREAMING_PY = (REPO_ROOT / "api" / "streaming.py").read_text() # ── Shared helpers for sprint-42 additional tests ──────────────────────────── REPO = REPO_ROOT # alias used by #427 tests _SESSIONS_JS = REPO_ROOT / 'static' / 'sessions.js' _STREAMING_PY = REPO_ROOT / 'api' / 'streaming.py' _MESSAGES_JS = REPO_ROOT / 'static' / 'messages.js' _UI_JS = REPO_ROOT / 'static' / 'ui.js' def _read_sessions_js(): return _SESSIONS_JS.read_text(encoding='utf-8') # ───────────────────────────────────────────────────────────────────────────── class TestSessionDBInjection(unittest.TestCase): """Verify SessionDB is initialized and passed to AIAgent in streaming.py.""" def test_hermes_state_import_present(self): """SessionDB must be imported from hermes_state inside _run_agent_streaming.""" self.assertIn( "from hermes_state import SessionDB", STREAMING_PY, "SessionDB import missing from streaming.py (PR #356)", ) def test_session_db_kwarg_passed_to_agent(self): """session_db= must be passed to the AIAgent constructor call.""" self.assertIn( "session_db=_session_db", STREAMING_PY, "session_db kwarg not passed to AIAgent (PR #356)", ) def test_sessiondb_init_in_try_except(self): """SessionDB init must be wrapped in try/except for non-fatal failure handling.""" # Check that SessionDB init is wrapped in the helper used by streaming. helper_start = STREAMING_PY.find("def _build_session_db_for_stream") helper_end = STREAMING_PY.find("\n\ndef _attempt_credential_self_heal", helper_start) self.assertGreater(helper_start, -1, "session DB helper missing in streaming.py") helper_src = STREAMING_PY[helper_start:helper_end] pattern = ( r"def _build_session_db_for_stream" r"[\s\S]*?try:\s*\n[\s\S]*?from hermes_state import SessionDB[\s\S]*?return SessionDB\(db_path=state_db_path\)[\s\S]*?except Exception as _db_err:" ) self.assertRegex( helper_src, pattern, "SessionDB init helper must use try/except for non-fatal error handling", ) def test_sessiondb_failure_logs_warning(self): """A failure initializing SessionDB must print a WARNING (not silently drop the error).""" self.assertIn( "WARNING: SessionDB init failed", STREAMING_PY, "SessionDB init failure must log a WARNING message (PR #356)", ) def test_session_db_initialized_before_agent_construction(self): """SessionDB initialization must appear before the AIAgent(...) constructor call.""" db_pos = STREAMING_PY.find("from hermes_state import SessionDB") agent_pos = STREAMING_PY.find("session_db=_session_db") self.assertGreater( agent_pos, db_pos, "SessionDB init must appear before AIAgent construction (PR #356)", ) def test_session_db_default_is_none(self): """SessionDB should now be initialized through the helper call.""" pattern = "_state_db_path = (Path(_profile_home) / \"state.db\") if _profile_home else None" helper_pattern = "_session_db = _build_session_db_for_stream(_state_db_path)" self.assertIn( pattern, STREAMING_PY, "_state_db_path should be resolved from profile home in streaming.py", ) self.assertIn( helper_pattern, STREAMING_PY, "_session_db should be initialized via _build_session_db_for_stream in streaming.py", ) class TestRuntimeRouteInjection(unittest.TestCase): """Verify WebUI forwards the resolved runtime route into AIAgent.""" def test_runtime_provider_keys_are_forwarded_to_agent(self): """WebUI must pass the runtime route fields that CLI already uses. Since issue #772 these are passed defensively via inspect-guarded kwargs so the WebUI degrades gracefully against older hermes-agent builds. """ for snippet in ( "_agent_kwargs['api_mode'] = _rt.get('api_mode')", "_agent_kwargs['acp_command'] = _rt.get('command')", "_agent_kwargs['acp_args'] = _rt.get('args')", "_agent_kwargs['credential_pool'] = _rt.get('credential_pool')", ): self.assertIn( snippet, STREAMING_PY, f"Missing defensive runtime route forwarding in streaming.py: {snippet}", ) def test_runtime_route_is_forwarded_from_resolver_into_agent_init(self): """The resolved ACP route should be passed through to AIAgent kwargs.""" import api.streaming as streaming captured = {} fake_session_db = object() resolve_runtime_provider = mock.Mock( return_value={ "provider": "openai-codex", "base_url": "https://api.openai.com/v1", "api_key": "rt-key", "api_mode": "codex_responses", "command": "codex", "args": ["exec", "--json"], "credential_pool": "openai-codex", } ) class FakeSession: def __init__(self): self.session_id = "sess-runtime-route" self.title = "Existing title" self.workspace = "/tmp" self.model = "gpt-5.4" self.messages = [] self.personality = None self.input_tokens = 0 self.output_tokens = 0 self.estimated_cost = None self.tool_calls = [] self.active_stream_id = None self.pending_user_message = None self.pending_attachments = [] self.pending_started_at = None def save(self, touch_updated_at=True): self._saved = True def compact(self): return { "session_id": self.session_id, "title": self.title, "workspace": self.workspace, "model": self.model, "created_at": 0, "updated_at": 0, "pinned": False, "archived": False, "project_id": None, "profile": None, "input_tokens": self.input_tokens, "output_tokens": self.output_tokens, "estimated_cost": self.estimated_cost, "personality": self.personality, } class CapturingAgent: def __init__(self, model=None, provider=None, base_url=None, api_key=None, api_mode=None, acp_command=None, acp_args=None, credential_pool=None, platform=None, quiet_mode=False, enabled_toolsets=None, fallback_model=None, session_id=None, session_db=None, stream_delta_callback=None, reasoning_callback=None, tool_progress_callback=None, clarify_callback=None, **kwargs): captured["init_kwargs"] = dict( model=model, provider=provider, base_url=base_url, api_key=api_key, api_mode=api_mode, acp_command=acp_command, acp_args=acp_args, credential_pool=credential_pool, platform=platform, quiet_mode=quiet_mode, enabled_toolsets=enabled_toolsets, fallback_model=fallback_model, session_id=session_id, session_db=session_db, stream_delta_callback=stream_delta_callback, reasoning_callback=reasoning_callback, tool_progress_callback=tool_progress_callback, clarify_callback=clarify_callback, ) self.session_id = session_id self.context_compressor = None self.session_prompt_tokens = 0 self.session_completion_tokens = 0 self.session_estimated_cost_usd = None self.reasoning_config = None self.ephemeral_system_prompt = None self._last_error = None def run_conversation(self, **kwargs): captured["run_kwargs"] = kwargs return { "messages": [ {"role": "user", "content": kwargs["persist_user_message"]}, {"role": "assistant", "content": "ok"}, ] } def interrupt(self, _message): captured["interrupted"] = True fake_session = FakeSession() fake_stream_id = "stream-runtime-route" fake_session.active_stream_id = fake_stream_id fake_queue = queue.Queue() fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider") fake_runtime_module.resolve_runtime_provider = resolve_runtime_provider fake_hermes_cli = types.ModuleType("hermes_cli") fake_hermes_cli.runtime_provider = fake_runtime_module fake_hermes_state = types.ModuleType("hermes_state") fake_hermes_state.SessionDB = mock.Mock(return_value=fake_session_db) with mock.patch.object(streaming, "get_session", return_value=fake_session), \ mock.patch.object(streaming, "_get_ai_agent", return_value=CapturingAgent), \ mock.patch.object(streaming, "resolve_model_provider", return_value=("gpt-5.4", "openai-codex", None)), \ mock.patch("api.config.get_config", return_value={}), \ mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \ mock.patch.dict( sys.modules, { "hermes_cli": fake_hermes_cli, "hermes_cli.runtime_provider": fake_runtime_module, "hermes_state": fake_hermes_state, }, ): streaming.STREAMS[fake_stream_id] = fake_queue streaming._run_agent_streaming( session_id=fake_session.session_id, msg_text="hello from webui", model="gpt-5.4", workspace="/tmp", stream_id=fake_stream_id, ) resolve_runtime_provider.assert_called_once_with(requested="openai-codex") init_kwargs = captured["init_kwargs"] self.assertEqual(init_kwargs["api_mode"], "codex_responses") self.assertEqual(init_kwargs["acp_command"], "codex") self.assertEqual(init_kwargs["acp_args"], ["exec", "--json"]) self.assertEqual(init_kwargs["credential_pool"], "openai-codex") self.assertEqual(init_kwargs["api_key"], "rt-key") self.assertIs(init_kwargs["session_db"], fake_session_db) def test_runtime_provider_forwards_interim_assistant_callback(self): """WebUI must pass interim_assistant_callback to AIAgent and emit SSE events.""" import api.streaming as streaming captured = {} class CapturingAgent: def __init__( self, model=None, provider=None, base_url=None, api_key=None, platform=None, quiet_mode=False, enabled_toolsets=None, fallback_model=None, session_id=None, session_db=None, stream_delta_callback=None, reasoning_callback=None, tool_progress_callback=None, interim_assistant_callback=None, clarify_callback=None, **kwargs, ): captured["init_kwargs"] = dict( model=model, provider=provider, base_url=base_url, api_key=api_key, platform=platform, quiet_mode=quiet_mode, enabled_toolsets=enabled_toolsets, fallback_model=fallback_model, session_id=session_id, session_db=session_db, stream_delta_callback=stream_delta_callback, reasoning_callback=reasoning_callback, tool_progress_callback=tool_progress_callback, interim_assistant_callback=interim_assistant_callback, clarify_callback=clarify_callback, ) self.session_id = session_id self.context_compressor = None self.session_prompt_tokens = 0 self.session_completion_tokens = 0 self.session_estimated_cost_usd = None self.reasoning_config = None self.ephemeral_system_prompt = None self._last_error = None self.interim_assistant_callback = interim_assistant_callback captured["agent"] = self def run_conversation(self, **kwargs): if self.interim_assistant_callback: self.interim_assistant_callback("Inspecting repo structure.", already_streamed=False) return { "messages": [ {"role": "user", "content": kwargs.get("persist_user_message", "")}, {"role": "assistant", "content": "ok"}, ] } def interrupt(self, _message): captured["interrupted"] = True class FakeSession: session_id = "sess-interim-test" title = "Test" workspace = "/tmp" model = "gpt-4o" messages = [] personality = None input_tokens = 0 output_tokens = 0 estimated_cost = None tool_calls = [] active_stream_id = None pending_user_message = None pending_attachments = [] pending_started_at = None def save(self, touch_updated_at=True, skip_index=True): pass def compact(self): return { "session_id": self.session_id, "title": self.title, "workspace": self.workspace, "model": self.model, "created_at": 0, "updated_at": 0, "pinned": False, "archived": False, "project_id": None, "profile": None, "input_tokens": 0, "output_tokens": 0, "estimated_cost": None, "personality": None, } @property def path(self): return "/tmp/fake.json" fake_stream_id = "stream-interim-callback" fake_queue = queue.Queue() fake_rt_module = types.ModuleType("hermes_cli.runtime_provider") fake_rt_module.resolve_runtime_provider = mock.Mock(return_value={ "provider": "openai-codex", "base_url": "https://api.openai.com/v1", "api_key": "rt-key", "api_mode": "codex_responses", "command": "codex", "args": ["exec", "--json"], "credential_pool": object(), }) fake_hermes_cli = types.ModuleType("hermes_cli") fake_hermes_cli.runtime_provider = fake_rt_module fake_hermes_state = types.ModuleType("hermes_state") fake_hermes_state.SessionDB = mock.Mock(return_value=object()) fake_session = FakeSession() fake_session.active_stream_id = fake_stream_id with mock.patch.object(streaming, "get_session", return_value=fake_session), \ mock.patch.object(streaming, "_get_ai_agent", return_value=CapturingAgent), \ mock.patch.object(streaming, "resolve_model_provider", return_value=("gpt-4o", "openai-codex", None)), \ mock.patch("api.config.get_config", return_value={}), \ mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \ mock.patch.dict(sys.modules, { "hermes_cli": fake_hermes_cli, "hermes_cli.runtime_provider": fake_rt_module, "hermes_state": fake_hermes_state, }): streaming.STREAMS[fake_stream_id] = fake_queue streaming._run_agent_streaming( session_id="sess-interim-test", msg_text="hello", model="gpt-4o", workspace="/tmp", stream_id=fake_stream_id, ) init_kwargs = captured["init_kwargs"] self.assertIsNotNone(init_kwargs["interim_assistant_callback"]) self.assertTrue(callable(init_kwargs["interim_assistant_callback"])) self.assertIn("WebUI progress guidance", captured["agent"].ephemeral_system_prompt) self.assertIn("Match the normal Hermes messaging style", captured["agent"].ephemeral_system_prompt) self.assertIn( "do not let long tool-running WebUI turns appear silent", captured["agent"].ephemeral_system_prompt, ) self.assertIn( "emit brief user-visible progress updates as normal assistant content", captured["agent"].ephemeral_system_prompt, ) self.assertIn( "Before the first tool batch in a long task", captured["agent"].ephemeral_system_prompt, ) self.assertIn( "Do not run many independent tool batches back-to-back without visible assistant text between them", captured["agent"].ephemeral_system_prompt, ) self.assertIn( "Do not keep progress only in reasoning, thinking, or tool-result channels", captured["agent"].ephemeral_system_prompt, ) self.assertNotIn( "you may provide brief user-visible progress updates", captured["agent"].ephemeral_system_prompt, ) interim_events = [] while not fake_queue.empty(): try: item = fake_queue.get_nowait() if isinstance(item, tuple) and len(item) >= 2: interim_events.append((item[0], item[1])) else: interim_events.append(item) except queue.Empty: break self.assertTrue( any(event == "interim_assistant" for event, _ in interim_events), "interim_assistant callback should emit interim_assistant SSE events", ) self.assertTrue( any( event == "interim_assistant" and event_data.get("text") == "Inspecting repo structure." for event, event_data in interim_events ), "interim_assistant event should carry the assistant commentary text" ) def test_clarify_callback_passes_configured_timeout_seconds(self): """clarify prompt data should use clarify.timeout from config when present.""" import api.streaming as streaming captured = {} submit_payloads = [] class FakeEntry: def __init__(self, value): self.result = value self.event = threading.Event() self.event.set() def fake_submit_pending(_sid, payload): submit_payloads.append(payload) return FakeEntry("selected") class CapturingAgent: def __init__(self, model=None, provider=None, base_url=None, api_key=None, platform=None, quiet_mode=False, enabled_toolsets=None, fallback_model=None, session_id=None, session_db=None, stream_delta_callback=None, reasoning_callback=None, tool_progress_callback=None, clarify_callback=None, **kwargs): self.clarify_callback = clarify_callback self.session_id = session_id captured["init_kwargs"] = { "clarify_callback": clarify_callback, } def run_conversation(self, **kwargs): if self.clarify_callback: captured["clarify_result"] = self.clarify_callback( "Need user confirmation", ["first", "second"], ) return { "messages": [ {"role": "user", "content": kwargs.get("persist_user_message", "")}, {"role": "assistant", "content": "ok"}, ] } def interrupt(self, _message): captured["interrupted"] = True class FakeSession: session_id = "sess-clarify-timeout" title = "clarify-timeout test" workspace = "/tmp" model = "gpt-5.4" messages = [] personality = None input_tokens = 0 output_tokens = 0 estimated_cost = None tool_calls = [] active_stream_id = None pending_user_message = None pending_attachments = [] pending_started_at = None def save(self, touch_updated_at=True, **_kwargs): pass def compact(self): return { "session_id": self.session_id, "title": self.title, "workspace": self.workspace, "model": self.model, "created_at": 0, "updated_at": 0, "pinned": False, "archived": False, "project_id": None, "profile": None, "input_tokens": 0, "output_tokens": 0, "estimated_cost": None, "personality": None, } @property def path(self): return "/tmp/fake.json" fake_stream_id = "stream-clarify-timeout" fake_queue = queue.Queue() fake_rt_module = types.ModuleType("hermes_cli.runtime_provider") fake_rt_module.resolve_runtime_provider = mock.Mock(return_value={ "provider": "openai-codex", "base_url": "https://api.openai.com/v1", "api_key": "rt-key", "api_mode": "codex_responses", "command": "codex", "args": ["exec", "--json"], "credential_pool": object(), }) fake_hermes_cli = types.ModuleType("hermes_cli") fake_hermes_cli.runtime_provider = fake_rt_module fake_hermes_state = types.ModuleType("hermes_state") fake_hermes_state.SessionDB = mock.Mock(return_value=object()) fake_session = FakeSession() fake_session.active_stream_id = fake_stream_id with mock.patch.object(streaming, "get_session", return_value=fake_session), \ mock.patch.object(streaming, "_get_ai_agent", return_value=CapturingAgent), \ mock.patch.object(streaming, "resolve_model_provider", return_value=("gpt-5.4", "openai-codex", None)), \ mock.patch.object(streaming, "get_config", return_value={"clarify": {"timeout": 300}}), \ mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \ mock.patch("api.clarify.submit_pending", side_effect=fake_submit_pending), \ mock.patch.dict(sys.modules, { "hermes_cli": fake_hermes_cli, "hermes_cli.runtime_provider": fake_rt_module, "hermes_state": fake_hermes_state, }): streaming.STREAMS[fake_stream_id] = fake_queue streaming._run_agent_streaming( session_id="sess-clarify-timeout", msg_text="please run task", model="gpt-5.4", workspace="/tmp", stream_id=fake_stream_id, ) self.assertEqual(captured["clarify_result"], "selected") self.assertEqual(len(submit_payloads), 1) self.assertEqual(submit_payloads[0]["timeout_seconds"], 300) class TestSessionDBAST(unittest.TestCase): """AST-level checks: verify the try/except is not inside _ENV_LOCK (deadlock guard).""" def setUp(self): self.tree = ast.parse(STREAMING_PY) def test_sessiondb_try_not_inside_env_lock(self): """The try block that wraps SessionDB init must NOT be inside a 'with _ENV_LOCK:' block. Putting a try/except inside _ENV_LOCK is the deadlock pattern caught by test_sprint34. The SessionDB try/except is outside the lock scope, which is correct. """ # Find all 'with _ENV_LOCK:' nodes; check none of their bodies contain # a Try node that also contains 'from hermes_state import SessionDB' for node in ast.walk(self.tree): if not isinstance(node, ast.With): continue names = [getattr(item.context_expr, "id", "") for item in node.items] if "_ENV_LOCK" not in names: continue # Walk the with-body for Try nodes for stmt in node.body: if isinstance(stmt, ast.Try): # Check if this try imports hermes_state src = ast.unparse(stmt) self.assertNotIn( "hermes_state", src, "SessionDB try/except must NOT be inside _ENV_LOCK body (deadlock risk)", ) class TestModelCustomInput(unittest.TestCase): """Tests for issue #444 — custom model ID input in model dropdown.""" STATIC = pathlib.Path(__file__).parent.parent / 'static' def _read(self, filename): path = self.STATIC / filename with open(path, 'r', encoding='utf-8') as f: return f.read() def _renderModelDropdown_body(self): src = self._read('ui.js') start = src.find('function renderModelDropdown()') end = src.find('\nasync function selectModelFromDropdown', start) return src[start:end] def test_model_custom_input_in_dropdown(self): body = self._renderModelDropdown_body() self.assertIn('model-custom-input', body, 'model-custom-input class must be in renderModelDropdown') def test_model_custom_enter_handler(self): body = self._renderModelDropdown_body() self.assertIn('_applyCustom', body, '_applyCustom function must be defined in renderModelDropdown') def test_model_custom_css_defined(self): css = self._read('style.css') self.assertIn('.model-custom-row', css, '.model-custom-row must be defined in style.css') self.assertIn('.model-custom-input', css, '.model-custom-input must be defined in style.css') def test_model_custom_i18n_keys(self): i18n = self._read('i18n.js') # Find en locale block (appears first before es) en_block_start = i18n.find("'en'") es_block_start = i18n.find("'es'") en_block = i18n[en_block_start:es_block_start] self.assertIn('model_custom_label', en_block, 'model_custom_label must be in en locale') self.assertIn('model_custom_placeholder', en_block, 'model_custom_placeholder must be in en locale') # ── Sprint 42 additional tests: context indicator (#437) ───────────────── def test_context_indicator_uses_pick_helper(): """The _pick helper must be present in sessions.js to prefer latest over stale values.""" content = _read_sessions_js() assert '_pick' in content, "_pick helper not found in static/sessions.js" def test_context_indicator_old_pattern_removed(): """The old || pattern that preferred stale session data must be gone.""" content = _read_sessions_js() assert '_s.input_tokens||u.input_tokens' not in content, \ "Old stale-data-first pattern '_s.input_tokens||u.input_tokens' still present in static/sessions.js" def test_context_indicator_all_six_fields(): """All six token/cost fields must appear in the _syncCtxIndicator call.""" content = _read_sessions_js() fields = [ 'input_tokens', 'output_tokens', 'estimated_cost', 'context_length', 'last_prompt_tokens', 'threshold_tokens', ] for field in fields: assert field in content, \ f"Field '{field}' not found in static/sessions.js _syncCtxIndicator call" # ── Sprint 42 additional tests: system prompt title (#441) ────────────── def test_system_prompt_title_guard_exists(): """The guard that detects [SYSTEM: prefixes must be present in sessions.js.""" content = _read_sessions_js() assert '[SYSTEM:' in content, \ "sessions.js must contain the [SYSTEM: guard to intercept system-prompt titles" # Make sure it appears in an if-condition context, not just a comment assert "cleanTitle.startsWith('[SYSTEM:')" in content, \ "sessions.js must have: cleanTitle.startsWith('[SYSTEM:') guard expression" def test_cleanTitle_is_let_not_const(): """cleanTitle must be declared with let (not const) to allow reassignment in the guard.""" content = _read_sessions_js() assert 'let cleanTitle' in content, \ "cleanTitle must be declared with 'let' (not 'const') to allow reassignment" # Make sure the old const form is gone in this context # (check the specific assignment line pattern) assert "const cleanTitle=tags.length" not in content, \ "Old 'const cleanTitle=tags.length...' must be replaced by 'let cleanTitle=...'" # ── Sprint 42 additional tests: thinking panel persistence (#427) ──────── def test_streaming_persists_reasoning_in_session(): """streaming.py must accumulate reasoning and patch assistant messages.""" src = (REPO / 'api' / 'streaming.py').read_text() # #3587: per-message reasoning segments replaced the flat _reasoning_text accumulator assert "_reasoning_segments" in src, \ "_reasoning_segments dict not found in streaming.py" # on_reasoning must accumulate non-echo reasoning into segments assert '_reasoning_segments[_current_reasoning_idx]' in src or '_reasoning_segments.get(_current_reasoning_idx' in src, \ "on_reasoning callback does not accumulate into per-message _reasoning_segments" assert '_is_visible_output_echo(reasoning_delta)' in src, \ "on_reasoning callback should suppress reasoning deltas that only echo visible streamed output" # Persistence block must exist before raw_session is built assert "Persist reasoning trace in the session so it survives reload" in src, \ "Reasoning persistence comment not found in streaming.py" # #3455: reasoning is now persisted via the think-split path — either the # merged reasoning (inline + on_reasoning stream) or the existing # _reasoning_text when content has no leading block. Both set _rm['reasoning']. assert "_rm['reasoning'] = _merged_reasoning" in src, \ "Code to set the last assistant message's reasoning (merged think-split) not found" assert "_split_thinking_from_content(" in src, \ "server-side think-split must run before save (#3455)" assert "_rm['reasoning'] = _existing_reasoning" in src, \ "the no-think-block branch must still persist _reasoning_text into the assistant message" # Persistence block must come BEFORE raw_session assignment persist_idx = src.index("Persist reasoning trace in the session") raw_session_idx = src.index("raw_session = s.compact()") assert persist_idx < raw_session_idx, \ "Reasoning persistence block must appear before raw_session assignment" def test_done_handler_patches_reasoning_field(): """messages.js done SSE handler must patch reasoningText onto the last assistant message.""" src = (REPO / 'static' / 'messages.js').read_text() # The persistence comment must be present inside the done handler assert "Persist reasoning trace for Worklog Thinking Cards" in src, \ "Reasoning persistence comment not found in messages.js done handler" # The guard and assignment must be present assert "if(reasoningText&&lastAsst&&!lastAsst.reasoning)" in src, \ "reasoningText guard not found in messages.js" assert "lastAsst.reasoning=reasoningText" in src, \ "lastAsst.reasoning assignment not found in messages.js" # Verify the patch is inside the done handler (after 'source.addEventListener' for done) done_handler_idx = src.index("source.addEventListener('done'") persist_idx = src.index("Persist reasoning trace for Worklog Thinking Cards") assert done_handler_idx < persist_idx, \ "Reasoning persistence patch must be inside the done SSE handler" # The guard must also check !lastAsst.reasoning to avoid overwriting server value assert "!lastAsst.reasoning" in src, \ "Guard '!lastAsst.reasoning' missing — would overwrite server-persisted reasoning" def test_rendermessages_keeps_reasoning_metadata_out_of_worklog_display(): """ui.js renderMessages must not promote provider reasoning metadata into Worklog prose.""" src = (REPO / 'static' / 'ui.js').read_text() sig_fn = src.split("function _messageHasReasoningPayload(m)", 1)[1].split("function", 1)[0] assert 'm.reasoning' in sig_fn, \ "m.reasoning should remain part of metadata/cache signature handling" # Legacy thinking-card helpers may still exist for explicit debug surfaces. assert 'thinking-card' in src, \ "thinking-card CSS class not found in ui.js" extraction = src.split("let thinkingText='';", 1)[1].split("const isUser=m.role==='user';", 1)[0] assert 'm.reasoning' not in extraction assert 'm.reasoning_content' not in extraction def test_streaming_restores_prior_reasoning_metadata_after_followup(): """Previous-turn thinking must survive later turns. The provider-facing history strips WebUI-only `reasoning` fields, so the streaming path must merge that metadata back onto the returned message history before saving the session, including reinserting dropped reasoning-only assistant segments. """ src = (REPO / 'api' / 'streaming.py').read_text() assert "def _restore_reasoning_metadata(" in src, \ "streaming.py must define a helper to restore prior reasoning metadata" assert "_next_context_messages" in src and "s.context_messages" in src, \ "streaming.py must restore prior reasoning metadata into model context" assert "s.messages = _merge_display_messages_after_agent_result(" in src, \ "streaming.py must merge restored result messages into the visible transcript" assert "updated_messages.insert(safe_pos, copy.deepcopy(prev_msg))" in src, \ "streaming.py must reinsert dropped reasoning-only assistant messages" def test_routes_restores_prior_reasoning_metadata_after_followup(): """The non-streaming route path must preserve prior reasoning metadata too.""" src = (REPO / 'api' / 'routes.py').read_text() assert "_restore_reasoning_metadata" in src, \ "routes.py must import reasoning metadata restoration helper" assert "_next_context_messages" in src and "s.context_messages" in src, \ "routes.py must restore prior reasoning metadata into model context" assert 's.messages = _merge_display_messages_after_agent_result(' in src, \ "routes.py must merge restored result messages into the visible transcript" class TestCredentialPoolBackwardCompat(unittest.TestCase): """Verify credential_pool and other newer kwargs are skipped gracefully when running against an older hermes-agent that lacks them (issue #772).""" def test_older_agent_without_credential_pool_does_not_crash(self): """WebUI must not crash with TypeError when AIAgent lacks credential_pool.""" import api.streaming as streaming captured = {} class OlderAgent: """Simulates a hermes-agent build that predates credential_pool.""" def __init__(self, model=None, provider=None, base_url=None, api_key=None, platform=None, quiet_mode=False, enabled_toolsets=None, fallback_model=None, session_id=None, session_db=None, stream_delta_callback=None, reasoning_callback=None, tool_progress_callback=None, clarify_callback=None): # No api_mode / acp_command / acp_args / credential_pool params captured["init_kwargs"] = {"session_id": session_id, "model": model} self.session_id = session_id self.context_compressor = None self.session_prompt_tokens = 0 self.session_completion_tokens = 0 self.session_estimated_cost_usd = None self.reasoning_config = None self.ephemeral_system_prompt = None self._last_error = None def run_conversation(self, **kwargs): return { "messages": [ {"role": "user", "content": kwargs.get("persist_user_message", "")}, {"role": "assistant", "content": "ok"}, ] } def interrupt(self, _message): pass class FakeSession: session_id = "sess-compat-test" title = "Test" workspace = "/tmp" model = "gpt-4o" messages = [] personality = None input_tokens = 0 output_tokens = 0 estimated_cost = None tool_calls = [] active_stream_id = None pending_user_message = None pending_attachments = [] pending_started_at = None def save(self, touch_updated_at=True): pass def compact(self): return { "session_id": self.session_id, "title": self.title, "workspace": self.workspace, "model": self.model, "created_at": 0, "updated_at": 0, "pinned": False, "archived": False, "project_id": None, "profile": None, "input_tokens": 0, "output_tokens": 0, "estimated_cost": None, "personality": None, } fake_stream_id = "stream-compat-test" fake_queue = queue.Queue() fake_rt_module = types.ModuleType("hermes_cli.runtime_provider") fake_rt_module.resolve_runtime_provider = mock.Mock(return_value={ "provider": "openai", "base_url": None, "api_key": "sk-test", "api_mode": "chat_completions", "command": None, "args": [], "credential_pool": object(), }) fake_hermes_cli = types.ModuleType("hermes_cli") fake_hermes_cli.runtime_provider = fake_rt_module fake_hermes_state = types.ModuleType("hermes_state") fake_hermes_state.SessionDB = mock.Mock(return_value=None) fake_session = FakeSession() fake_session.active_stream_id = fake_stream_id with mock.patch.object(streaming, "get_session", return_value=fake_session), \ mock.patch.object(streaming, "_get_ai_agent", return_value=OlderAgent), \ mock.patch.object(streaming, "resolve_model_provider", return_value=("gpt-4o", "openai", None)), \ mock.patch("api.config.get_config", return_value={}), \ mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \ mock.patch.dict(sys.modules, { "hermes_cli": fake_hermes_cli, "hermes_cli.runtime_provider": fake_rt_module, "hermes_state": fake_hermes_state, }): streaming.STREAMS[fake_stream_id] = fake_queue # Must not raise TypeError streaming._run_agent_streaming( session_id="sess-compat-test", msg_text="hello", model="gpt-4o", workspace="/tmp", stream_id=fake_stream_id, ) # Agent was constructed successfully self.assertIn("session_id", captured["init_kwargs"]) self.assertEqual(captured["init_kwargs"]["session_id"], "sess-compat-test") class TestAgentCacheCredentialPoolStability(unittest.TestCase): """Credential-pool token churn must not evict cached WebUI agents.""" def test_credential_pool_signature_ignores_volatile_runtime_token(self): import api.streaming as streaming pool = object() self.assertEqual( streaming._agent_cache_api_key_sig('token-a', pool), streaming._agent_cache_api_key_sig('token-b', pool), ) self.assertNotEqual( streaming._agent_cache_api_key_sig('token-a', None), streaming._agent_cache_api_key_sig('token-b', None), ) def test_cached_agent_runtime_refresh_swaps_key_without_losing_agent_state(self): import api.streaming as streaming class FakeAgent: def __init__(self): self.api_key = 'old-token' self.base_url = 'https://chatgpt.com/backend-api/codex' self.api_mode = 'codex_responses' self._client_kwargs = { 'api_key': 'old-token', 'base_url': self.base_url, 'default_headers': {'old': 'header'}, } self._credential_pool = 'old-pool' self.context_compressor = type('Compressor', (), { 'base_url': self.base_url, 'api_key': 'old-token', })() self._primary_runtime = { 'base_url': self.base_url, 'api_key': 'old-token', 'client_kwargs': dict(self._client_kwargs), 'compressor_base_url': self.base_url, 'compressor_api_key': 'old-token', } self.header_refreshes = [] self.replacements = [] self.prefetch_survives = object() def _apply_client_headers_for_base_url(self, base_url): self.header_refreshes.append((base_url, self._client_kwargs['api_key'])) self._client_kwargs['default_headers'] = {'refreshed-for': self._client_kwargs['api_key']} def _replace_primary_openai_client(self, *, reason): self.replacements.append(reason) return True agent = FakeAgent() preserved = agent.prefetch_survives changed = streaming._refresh_cached_agent_runtime(agent, { 'api_key': 'new-token', 'base_url': 'https://chatgpt.com/backend-api/codex', 'credential_pool': 'new-pool', }) self.assertTrue(changed) self.assertIs(agent.prefetch_survives, preserved) self.assertEqual(agent.api_key, 'new-token') self.assertEqual(agent._client_kwargs['api_key'], 'new-token') self.assertEqual(agent._credential_pool, 'new-pool') self.assertEqual(agent._primary_runtime['api_key'], 'new-token') self.assertEqual(agent._primary_runtime['client_kwargs']['api_key'], 'new-token') self.assertEqual(agent._primary_runtime['compressor_api_key'], 'new-token') self.assertEqual(getattr(agent.context_compressor, 'api_key'), 'new-token') self.assertEqual(agent.header_refreshes, [('https://chatgpt.com/backend-api/codex', 'new-token')]) self.assertEqual(agent.replacements, ['webui_credential_refresh']) def test_same_key_refresh_repairs_stale_primary_runtime_snapshot(self): import api.streaming as streaming class FakeAgent: api_key = 'current-token' base_url = 'https://chatgpt.com/backend-api/codex' api_mode = 'codex_responses' _client_kwargs = { 'api_key': 'current-token', 'base_url': 'https://chatgpt.com/backend-api/codex', } _primary_runtime = { 'api_key': 'old-token', 'base_url': 'https://chatgpt.com/backend-api/codex', 'client_kwargs': { 'api_key': 'old-token', 'base_url': 'https://chatgpt.com/backend-api/codex', }, } agent = FakeAgent() ok = streaming._refresh_cached_agent_runtime(agent, {'api_key': 'current-token'}) self.assertTrue(ok) self.assertEqual(agent._primary_runtime['api_key'], 'current-token') self.assertEqual(agent._primary_runtime['client_kwargs']['api_key'], 'current-token') def test_fallback_active_refresh_requests_rebuild_without_mutating_fallback(self): import api.streaming as streaming class FakeAgent: api_key = 'fallback-token' base_url = 'https://fallback.example/v1' api_mode = 'codex_responses' _fallback_activated = True _client_kwargs = { 'api_key': 'fallback-token', 'base_url': 'https://fallback.example/v1', } _primary_runtime = { 'api_key': 'old-primary-token', 'base_url': 'https://chatgpt.com/backend-api/codex', 'client_kwargs': { 'api_key': 'old-primary-token', 'base_url': 'https://chatgpt.com/backend-api/codex', }, 'compressor_api_key': 'old-primary-token', 'compressor_base_url': 'https://chatgpt.com/backend-api/codex', } agent = FakeAgent() ok = streaming._refresh_cached_agent_runtime(agent, { 'api_key': 'new-primary-token', 'base_url': 'https://chatgpt.com/backend-api/codex', }) self.assertFalse(ok) self.assertEqual(agent.api_key, 'fallback-token') self.assertEqual(agent._client_kwargs['api_key'], 'fallback-token') self.assertEqual(agent._primary_runtime['api_key'], 'old-primary-token') self.assertEqual(agent._primary_runtime['client_kwargs']['api_key'], 'old-primary-token')