c7e2e0b779
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>
148 lines
5.6 KiB
Python
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()
|
|
|
|
|