Files
hermes-agent/tests/gateway/test_choice_picker.py
T
Teknium c7e2e0b779 feat(fast): bounded /fast auto|cold windows behind one route-aware gate
Adds two bounded fast modes on top of the static /fast toggle, default OFF:

- `auto`: every user turn opens a `agent.fast_auto_seconds` (default 60s)
  window; requests inside it carry the provider fast param, later tool-loop
  requests fall back to standard pricing.
- `cold`: the same window, but only on the first turn of a session (no prior
  user/assistant/tool history).

agent/fast_mode.py holds the whole policy: `begin_turn()` at the
run_conversation ingress arms `agent._fast_until`; `effective_request_overrides()`
is consumed in the ONE place request_overrides feed the transports
(build_api_kwargs), so the fast param is a per-request kwarg only. System
prompt, tools and messages are untouched — the prompt cache is preserved.

resolve_fast_mode_overrides() is now the single gate for static and bounded
modes and accepts provider/base_url: OpenRouter, Nous, Copilot, Azure,
Bedrock and custom base_urls never receive service_tier/speed (#34308's
route gating). Both existing callers (CLI turn route, gateway turn route)
and the TUI config.set path pass the route.

Surfaces: config `agent.service_tier: auto|cold` + `agent.fast_auto_seconds`,
`/fast auto|cold` in CLI, gateway (picker gains both entries), TUI/desktop
config.set; status shows the mode; web dashboard select lists the real
values. Docs: configuration.md Fast Mode section with mode table + cost note,
slash-commands, cli-config.yaml.example, locale strings for the two picker
entries.

Salvages #89991 (bounded fast modes) and #34308 (route gating).
Fixes #64785, #74730.

Co-authored-by: Eva <239388517+100yenadmin@users.noreply.github.com>
Co-authored-by: kbaicai <kbaicai@qq.com>
2026-09-02 05:33:13 -07:00

148 lines
5.6 KiB
Python

"""Tests for the gateway interactive choice picker (/reasoning, /fast).
The picker mirrors the /model picker architecture: the gateway gates on the
adapter *type* exposing ``send_choice_picker``, sends a flat choice list, and
falls back to the text status card when the platform has no picker or the
send fails. Selection flows through the same application path as the typed
command, so picker and typed arguments can never diverge.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
import yaml
import gateway.run as gateway_run
from gateway.config import Platform
from gateway.platforms.base import MessageEvent, SendResult
from gateway.session import SessionSource
def _make_event(text="/reasoning", platform=Platform.TELEGRAM, user_id="12345", chat_id="67890"):
source = SessionSource(
platform=platform,
user_id=user_id,
chat_id=chat_id,
user_name="testuser",
)
return MessageEvent(text=text, source=source)
class _PickerAdapter:
"""Adapter whose *type* exposes ``send_choice_picker`` (the gate the
handler checks via ``getattr(type(adapter), 'send_choice_picker', None)``)."""
def __init__(self, success=True):
self.calls = []
self._success = success
async def send_choice_picker(self, **kwargs):
self.calls.append(kwargs)
return SendResult(success=self._success, message_id="m1")
class _NoPickerAdapter:
"""Adapter with no choice-picker capability."""
def _make_runner(adapter=None):
runner = object.__new__(gateway_run.GatewayRunner)
runner.adapters = {}
runner._ephemeral_system_prompt = ""
runner._prefill_messages = []
runner._reasoning_config = None
runner._session_reasoning_overrides = {}
runner._show_reasoning = False
runner._provider_routing = {}
runner._fallback_model = None
runner._running_agents = {}
runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock()
runner.hooks.loaded_hooks = []
runner._session_db = None
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
runner._adapter_for_source = lambda source: adapter
runner._thread_metadata_for_source = lambda source, anchor=None: {}
runner._reply_anchor_for_event = lambda event: None
return runner
class TestReasoningChoicePicker:
@pytest.mark.asyncio
async def test_bare_reasoning_sends_picker_when_adapter_supports_it(self, tmp_path, monkeypatch):
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
adapter = _PickerAdapter()
runner = _make_runner(adapter)
result = await runner._handle_reasoning_command(_make_event("/reasoning"))
assert result is None # picker sent — adapter owns the response
assert len(adapter.calls) == 1
call = adapter.calls[0]
values = [c["value"] for c in call["choices"]]
# Full canonical ladder + none + subcommands, in order
from hermes_constants import VALID_REASONING_EFFORTS
assert values[0] == "none"
assert values[1:1 + len(VALID_REASONING_EFFORTS)] == list(VALID_REASONING_EFFORTS)
assert values[-3:] == ["reset", "show", "hide"]
@pytest.mark.asyncio
async def test_picker_selection_applies_same_as_typed(self, tmp_path, monkeypatch):
"""The picker's on_choice_selected must produce the identical state
change as typing the argument (single application path)."""
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
adapter = _PickerAdapter()
runner = _make_runner(adapter)
event = _make_event("/reasoning")
session_key = runner._session_key_for_source(event.source)
await runner._handle_reasoning_command(event)
on_choice = adapter.calls[0]["on_choice_selected"]
reply = await on_choice(event.source.chat_id, "ultra")
assert "ultra" in reply
override = runner._session_reasoning_overrides.get(session_key)
assert override == {"enabled": True, "effort": "ultra"}
class TestFastChoicePicker:
def _patch_fast_support(self, monkeypatch, tmp_path):
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda cfg: "gpt-5.6")
import hermes_cli.models as models_mod
monkeypatch.setattr(models_mod, "model_supports_fast_mode", lambda m: True)
@pytest.mark.asyncio
async def test_bare_fast_sends_picker_when_adapter_supports_it(self, tmp_path, monkeypatch):
self._patch_fast_support(monkeypatch, tmp_path)
adapter = _PickerAdapter()
runner = _make_runner(adapter)
result = await runner._handle_fast_command(_make_event("/fast"))
assert result is None
values = [c["value"] for c in adapter.calls[0]["choices"]]
assert values == ["fast", "normal", "auto", "cold"]
@pytest.mark.asyncio
async def test_fast_picker_selection_is_session_scoped(self, tmp_path, monkeypatch):
"""A bare /fast picker tap applies a session override, not a config write."""
self._patch_fast_support(monkeypatch, tmp_path)
adapter = _PickerAdapter()
runner = _make_runner(adapter)
event = _make_event("/fast")
await runner._handle_fast_command(event)
on_choice = adapter.calls[0]["on_choice_selected"]
await on_choice(event.source.chat_id, "fast")
assert runner._service_tier == "priority"
assert runner._session_service_tier_overrides
assert not (tmp_path / "config.yaml").exists()