mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
When /model is called with no arguments in the interactive CLI, open a two-step prompt_toolkit modal instead of the previous text-only listing: 1. Provider selection — curses_single_select with all authenticated providers 2. Model selection — live API fetch with curated fallback Also fixes: - OpenAI Codex model normalization (openai/gpt-5.4 → gpt-5.4) - Dedicated Codex validation path using provider_model_ids() Preserves curses_radiolist (used by setup, tools, plugins) alongside the new curses_single_select. Retains tool elapsed timer in spinner. Cherry-picked from PR #7438 by MestreY0d4-Uninter.
254 lines
7.9 KiB
Python
254 lines
7.9 KiB
Python
"""Tests for the interactive CLI /model picker (provider → model drill-down)."""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
class _FakeBuffer:
|
|
def __init__(self, text="draft text"):
|
|
self.text = text
|
|
self.cursor_position = len(text)
|
|
self.reset_calls = []
|
|
|
|
def reset(self, append_to_history=False):
|
|
self.reset_calls.append(append_to_history)
|
|
self.text = ""
|
|
self.cursor_position = 0
|
|
|
|
|
|
def _make_providers():
|
|
return [
|
|
{
|
|
"slug": "openrouter",
|
|
"name": "OpenRouter",
|
|
"is_current": True,
|
|
"is_user_defined": False,
|
|
"models": ["anthropic/claude-opus-4.6", "openai/gpt-5.4"],
|
|
"total_models": 2,
|
|
"source": "built-in",
|
|
},
|
|
{
|
|
"slug": "anthropic",
|
|
"name": "Anthropic",
|
|
"is_current": False,
|
|
"is_user_defined": False,
|
|
"models": ["claude-opus-4.6", "claude-sonnet-4.6"],
|
|
"total_models": 2,
|
|
"source": "built-in",
|
|
},
|
|
{
|
|
"slug": "custom:my-ollama",
|
|
"name": "My Ollama",
|
|
"is_current": False,
|
|
"is_user_defined": True,
|
|
"models": ["llama3", "mistral"],
|
|
"total_models": 2,
|
|
"source": "user-config",
|
|
"api_url": "http://localhost:11434/v1",
|
|
},
|
|
]
|
|
|
|
|
|
def _make_picker_cli(picker_return_value):
|
|
cli = MagicMock()
|
|
cli._run_curses_picker = MagicMock(return_value=picker_return_value)
|
|
cli._app = MagicMock()
|
|
cli._status_bar_visible = True
|
|
return cli
|
|
|
|
|
|
def _make_modal_cli():
|
|
from cli import HermesCLI
|
|
|
|
cli = HermesCLI.__new__(HermesCLI)
|
|
cli.model = "gpt-5.4"
|
|
cli.provider = "openrouter"
|
|
cli.requested_provider = "openrouter"
|
|
cli.base_url = ""
|
|
cli.api_key = ""
|
|
cli.api_mode = ""
|
|
cli._explicit_api_key = ""
|
|
cli._explicit_base_url = ""
|
|
cli._pending_model_switch_note = None
|
|
cli._model_picker_state = None
|
|
cli._modal_input_snapshot = None
|
|
cli._status_bar_visible = True
|
|
cli._invalidate = MagicMock()
|
|
cli.agent = None
|
|
cli.config = {}
|
|
cli.console = MagicMock()
|
|
cli._app = SimpleNamespace(
|
|
current_buffer=_FakeBuffer(),
|
|
invalidate=MagicMock(),
|
|
)
|
|
return cli
|
|
|
|
|
|
def test_provider_selection_returns_slug_on_choice():
|
|
providers = _make_providers()
|
|
cli = _make_picker_cli(1)
|
|
from cli import HermesCLI
|
|
|
|
result = HermesCLI._interactive_provider_selection(cli, providers, "gpt-5.4", "OpenRouter")
|
|
|
|
assert result == "anthropic"
|
|
cli._run_curses_picker.assert_called_once()
|
|
|
|
|
|
def test_provider_selection_returns_none_on_cancel():
|
|
providers = _make_providers()
|
|
cli = _make_picker_cli(None)
|
|
from cli import HermesCLI
|
|
|
|
result = HermesCLI._interactive_provider_selection(cli, providers, "gpt-5.4", "OpenRouter")
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_provider_selection_default_is_current():
|
|
providers = _make_providers()
|
|
cli = _make_picker_cli(0)
|
|
from cli import HermesCLI
|
|
|
|
HermesCLI._interactive_provider_selection(cli, providers, "gpt-5.4", "OpenRouter")
|
|
|
|
assert cli._run_curses_picker.call_args.kwargs["default_index"] == 0
|
|
|
|
|
|
def test_model_selection_returns_model_on_choice():
|
|
provider_data = _make_providers()[0]
|
|
cli = _make_picker_cli(0)
|
|
from cli import HermesCLI
|
|
|
|
result = HermesCLI._interactive_model_selection(cli, provider_data["models"], provider_data)
|
|
|
|
assert result == "anthropic/claude-opus-4.6"
|
|
|
|
|
|
def test_model_selection_custom_entry_prompts_for_input():
|
|
provider_data = _make_providers()[0]
|
|
cli = _make_picker_cli(2)
|
|
from cli import HermesCLI
|
|
|
|
cli._prompt_text_input = MagicMock(return_value="my-custom-model")
|
|
result = HermesCLI._interactive_model_selection(cli, provider_data["models"], provider_data)
|
|
|
|
assert result == "my-custom-model"
|
|
cli._prompt_text_input.assert_called_once_with(" Enter model name: ")
|
|
|
|
|
|
def test_model_selection_empty_prompts_for_manual_input():
|
|
provider_data = {
|
|
"slug": "custom:empty",
|
|
"name": "Empty Provider",
|
|
"models": [],
|
|
"total_models": 0,
|
|
}
|
|
cli = _make_picker_cli(None)
|
|
from cli import HermesCLI
|
|
|
|
cli._prompt_text_input = MagicMock(return_value="my-model")
|
|
result = HermesCLI._interactive_model_selection(cli, [], provider_data)
|
|
|
|
assert result == "my-model"
|
|
cli._prompt_text_input.assert_called_once_with(" Enter model name manually (or Enter to cancel): ")
|
|
|
|
|
|
def test_prompt_text_input_uses_run_in_terminal_when_app_active():
|
|
from cli import HermesCLI
|
|
|
|
cli = _make_modal_cli()
|
|
|
|
with (
|
|
patch("prompt_toolkit.application.run_in_terminal", side_effect=lambda fn: fn()) as run_mock,
|
|
patch("builtins.input", return_value="manual-value"),
|
|
):
|
|
result = HermesCLI._prompt_text_input(cli, "Enter value: ")
|
|
|
|
assert result == "manual-value"
|
|
run_mock.assert_called_once()
|
|
assert cli._status_bar_visible is True
|
|
|
|
|
|
def test_should_handle_model_command_inline_uses_command_name_resolution():
|
|
from cli import HermesCLI
|
|
|
|
cli = _make_modal_cli()
|
|
|
|
with patch("hermes_cli.commands.resolve_command", return_value=SimpleNamespace(name="model")):
|
|
assert HermesCLI._should_handle_model_command_inline(cli, "/model") is True
|
|
|
|
with patch("hermes_cli.commands.resolve_command", return_value=SimpleNamespace(name="help")):
|
|
assert HermesCLI._should_handle_model_command_inline(cli, "/model") is False
|
|
|
|
assert HermesCLI._should_handle_model_command_inline(cli, "/model", has_images=True) is False
|
|
|
|
|
|
def test_process_command_model_without_args_opens_modal_picker_and_captures_draft():
|
|
from cli import HermesCLI
|
|
|
|
cli = _make_modal_cli()
|
|
providers = _make_providers()
|
|
|
|
with (
|
|
patch("hermes_cli.model_switch.list_authenticated_providers", return_value=providers),
|
|
patch("cli._cprint"),
|
|
):
|
|
result = cli.process_command("/model")
|
|
|
|
assert result is True
|
|
assert cli._model_picker_state is not None
|
|
assert cli._model_picker_state["stage"] == "provider"
|
|
assert cli._model_picker_state["selected"] == 0
|
|
assert cli._modal_input_snapshot == {"text": "draft text", "cursor_position": len("draft text")}
|
|
assert cli._app.current_buffer.text == ""
|
|
|
|
|
|
def test_model_picker_provider_then_model_selection_applies_switch_result_and_restores_draft():
|
|
from cli import HermesCLI
|
|
|
|
cli = _make_modal_cli()
|
|
providers = _make_providers()
|
|
|
|
with (
|
|
patch("hermes_cli.model_switch.list_authenticated_providers", return_value=providers),
|
|
patch("cli._cprint"),
|
|
):
|
|
assert cli.process_command("/model") is True
|
|
|
|
cli._model_picker_state["selected"] = 1
|
|
with patch("hermes_cli.models.provider_model_ids", return_value=["claude-opus-4.6", "claude-sonnet-4.6"]):
|
|
HermesCLI._handle_model_picker_selection(cli)
|
|
|
|
assert cli._model_picker_state["stage"] == "model"
|
|
assert cli._model_picker_state["provider_data"]["slug"] == "anthropic"
|
|
assert cli._model_picker_state["model_list"] == ["claude-opus-4.6", "claude-sonnet-4.6"]
|
|
|
|
cli._model_picker_state["selected"] = 0
|
|
switch_result = SimpleNamespace(
|
|
success=True,
|
|
error_message=None,
|
|
new_model="claude-opus-4.6",
|
|
target_provider="anthropic",
|
|
api_key="",
|
|
base_url="",
|
|
api_mode="anthropic_messages",
|
|
provider_label="Anthropic",
|
|
model_info=None,
|
|
warning_message=None,
|
|
provider_changed=True,
|
|
)
|
|
|
|
with (
|
|
patch("hermes_cli.model_switch.switch_model", return_value=switch_result) as switch_mock,
|
|
patch("cli._cprint"),
|
|
):
|
|
HermesCLI._handle_model_picker_selection(cli)
|
|
|
|
assert cli._model_picker_state is None
|
|
assert cli.model == "claude-opus-4.6"
|
|
assert cli.provider == "anthropic"
|
|
assert cli.requested_provider == "anthropic"
|
|
assert cli._app.current_buffer.text == "draft text"
|
|
switch_mock.assert_called_once()
|
|
assert switch_mock.call_args.kwargs["explicit_provider"] == "anthropic"
|