"""Regression tests for gateway preservation of provider-derived request_overrides. Named custom providers can return request_overrides (for example ``extra_body.text.verbosity`` for OpenAI Responses). The gateway must preserve those overrides on the runtime path and merge fast-mode overrides on top rather than replacing them with an empty dict. """ from __future__ import annotations import asyncio import sys import threading import types from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest import gateway.run as gateway_run from gateway.config import Platform from gateway.session import SessionSource class _CapturingAgent: last_init = None def __init__(self, *args, **kwargs): type(self).last_init = dict(kwargs) self.tools = [] self.request_overrides = dict(kwargs.get("request_overrides") or {}) def run_conversation(self, user_message: str, conversation_history=None, task_id=None): return { "final_response": "ok", "messages": [], "api_calls": 1, } def _install_fake_agent(monkeypatch): fake_run_agent = types.ModuleType("run_agent") fake_run_agent.AIAgent = _CapturingAgent monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) def _make_runner(): runner = object.__new__(gateway_run.GatewayRunner) runner.adapters = {} runner.session_store = None runner.config = None runner._voice_mode = {} runner._ephemeral_system_prompt = "" runner._prefill_messages = [] runner._reasoning_config = None runner._show_reasoning = False runner._provider_routing = {} runner._fallback_model = None runner._service_tier = None runner._running_agents = {} runner._running_agents_ts = {} runner._background_tasks = set() runner._session_db = None runner._session_model_overrides = {} runner._session_reasoning_overrides = {} runner._pending_model_notes = {} runner._pending_approvals = {} runner._agent_cache = {} runner._agent_cache_lock = threading.Lock() runner._get_or_create_gateway_honcho = lambda session_key: (None, None) runner.hooks = MagicMock() runner.hooks.emit = AsyncMock() runner.hooks.loaded_hooks = [] return runner def _make_source() -> SessionSource: return SessionSource( platform=Platform.FEISHU, chat_id="ou_test", chat_type="dm", user_id="user-1", user_name="tester", ) def test_resolve_runtime_agent_kwargs_preserves_request_overrides(monkeypatch): monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", lambda: { "api_key": "***", "base_url": "https://example.test/v1", "provider": "custom", "api_mode": "codex_responses", "command": None, "args": [], "credential_pool": None, "request_overrides": { "extra_body": {"text": {"verbosity": "low"}}, }, }, ) result = gateway_run._resolve_runtime_agent_kwargs() assert result["request_overrides"] == { "extra_body": {"text": {"verbosity": "low"}}, } def test_turn_route_preserves_provider_request_overrides_without_fast_mode(): runner = _make_runner() runner._service_tier = None runtime_kwargs = { "api_key": "***", "base_url": "https://example.test/v1", "provider": "custom", "api_mode": "codex_responses", "command": None, "args": [], "credential_pool": None, "request_overrides": { "extra_body": {"text": {"verbosity": "low"}}, }, } route = gateway_run.GatewayRunner._resolve_turn_agent_config( runner, "hi", "gpt-5.4", runtime_kwargs, ) assert route["request_overrides"] == { "extra_body": {"text": {"verbosity": "low"}}, } def test_turn_route_merges_fast_mode_with_provider_request_overrides(): runner = _make_runner() runner._service_tier = "priority" runtime_kwargs = { "api_key": "***", "base_url": "https://example.test/v1", "provider": "custom", "api_mode": "codex_responses", "command": None, "args": [], "credential_pool": None, "request_overrides": { "extra_body": {"text": {"verbosity": "low"}}, }, } with patch( "hermes_cli.models.resolve_fast_mode_overrides", return_value={"service_tier": "priority"}, ): route = gateway_run.GatewayRunner._resolve_turn_agent_config( runner, "hi", "gpt-5.4", runtime_kwargs, ) assert route["request_overrides"] == { "extra_body": {"text": {"verbosity": "low"}}, "service_tier": "priority", } @pytest.mark.asyncio async def test_run_agent_preserves_provider_request_overrides_on_gateway_path(monkeypatch): monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None) monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {}) monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") monkeypatch.setattr( gateway_run, "_resolve_runtime_agent_kwargs", lambda: { "provider": "custom", "api_mode": "codex_responses", "base_url": "https://example.test/v1", "api_key": "***", "request_overrides": { "extra_body": {"text": {"verbosity": "low"}}, }, }, ) _install_fake_agent(monkeypatch) import hermes_cli.tools_config as tools_config monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"}) runner = _make_runner() source = _make_source() session_key = "agent:main:feishu:dm:ou_test" runner.session_store = SimpleNamespace( get_or_create_session=lambda _source: SimpleNamespace(session_id="session-1"), load_transcript=lambda _session_id: [], ) _CapturingAgent.last_init = None result = await runner._run_agent( message="hi", context_prompt="", history=[], source=source, session_id="session-1", session_key=session_key, ) assert result["final_response"] == "ok" assert _CapturingAgent.last_init is not None assert _CapturingAgent.last_init["request_overrides"] == { "extra_body": {"text": {"verbosity": "low"}}, } @pytest.mark.asyncio async def test_reused_agent_turn_merges_request_overrides_not_overwrite(monkeypatch): """Merge-not-overwrite regression (salvaged from PR #52432). A cached/reused gateway agent must keep its init-time request_overrides (custom-provider extra_body) across turns: a /fast turn layers service_tier ON TOP, and the following normal turn drops only the stale fast-mode key while the provider extra_body survives. """ monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None) monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {}) monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") monkeypatch.setattr( gateway_run, "_resolve_runtime_agent_kwargs", lambda: { "provider": "custom", "api_mode": "codex_responses", "base_url": "https://example.test/v1", "api_key": "***", "request_overrides": { "extra_body": {"text": {"verbosity": "low"}}, }, }, ) _install_fake_agent(monkeypatch) import hermes_cli.tools_config as tools_config monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"}) runner = _make_runner() source = _make_source() session_key = "agent:main:feishu:dm:ou_test" runner.session_store = SimpleNamespace( get_or_create_session=lambda _source: SimpleNamespace(session_id="session-1"), load_transcript=lambda _session_id: [], ) seen_agents = [] orig_init = _CapturingAgent.__init__ def _tracking_init(self, *args, **kwargs): orig_init(self, *args, **kwargs) seen_agents.append(self) monkeypatch.setattr(_CapturingAgent, "__init__", _tracking_init) async def run_turn(): return await runner._run_agent( message="hi", context_prompt="", history=[], source=source, session_id="session-1", session_key=session_key, ) # Turn 1: /fast active — provider extra_body AND service_tier both present. # The turn path re-resolves the tier per session, so stub the resolver. tier_box = {"tier": "priority"} runner._resolve_session_service_tier = lambda *a, **k: tier_box["tier"] with patch( "hermes_cli.models.resolve_fast_mode_overrides", return_value={"service_tier": "priority"}, ): result = await run_turn() assert result["final_response"] == "ok" assert len(seen_agents) == 1 agent = seen_agents[0] assert agent.request_overrides == { "extra_body": {"text": {"verbosity": "low"}}, "service_tier": "priority", } # Turn 2: back to normal — the SAME cached agent must drop only the stale # fast-mode key; the init-time provider extra_body survives the refresh. tier_box["tier"] = None result = await run_turn() assert result["final_response"] == "ok" assert len(seen_agents) == 1, "agent should be reused from the gateway cache" assert agent.request_overrides == { "extra_body": {"text": {"verbosity": "low"}}, }