5aa1a50c57
- load_user_config_effective wrote backups/config/*.good.* into ANY home it read, so doctor and the TUI cwd lookup created backup dirs inside other profiles. The copy is now taken only when the path is the active home's config (the only home load_config ever backed up). - The effective-config test re-composed the implementation's own primitives (a mirror); it now pins a literal expected dict for a fixture of user file + managed overlay + env, and fail_closed asserts yaml.YAMLError. - Four repointed gateway tests carried duplicate _load_gateway_config setattr lines (one silently overriding the other); deduped to the intended dict.
304 lines
9.4 KiB
Python
304 lines
9.4 KiB
Python
"""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, "_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, "_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"}},
|
|
}
|