"""Tests for tool token estimation and curses_ui status_fn support.""" from unittest.mock import patch import pytest # tiktoken is not in core/[all] deps — skip estimation tests when unavailable _has_tiktoken = True try: import tiktoken # noqa: F401 except ImportError: _has_tiktoken = False _needs_tiktoken = pytest.mark.skipif(not _has_tiktoken, reason="tiktoken not installed") # ─── Token Estimation Tests ────────────────────────────────────────────────── @_needs_tiktoken def test_estimate_tool_tokens_returns_positive_counts(): """_estimate_tool_tokens should return a non-empty dict with positive values.""" from hermes_cli.tools_config import _estimate_tool_tokens, _tool_token_cache # Clear cache to force fresh computation import hermes_cli.tools_config as tc tc._tool_token_cache = None tokens = _estimate_tool_tokens() assert isinstance(tokens, dict) assert len(tokens) > 0 for name, count in tokens.items(): assert isinstance(name, str) assert isinstance(count, int) assert count > 0, f"Tool {name} has non-positive token count: {count}" @_needs_tiktoken def test_estimate_tool_tokens_is_cached(): """Second call should return the same cached dict object.""" import hermes_cli.tools_config as tc tc._tool_token_cache = None first = tc._estimate_tool_tokens() second = tc._estimate_tool_tokens() assert first is second def test_estimate_tool_tokens_returns_empty_when_tiktoken_unavailable(monkeypatch): """Graceful degradation when tiktoken cannot be imported.""" import hermes_cli.tools_config as tc tc._tool_token_cache = None import builtins real_import = builtins.__import__ def mock_import(name, *args, **kwargs): if name == "tiktoken": raise ImportError("mocked") return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", mock_import) result = tc._estimate_tool_tokens() assert result == {} # Reset cache for other tests tc._tool_token_cache = None @_needs_tiktoken def test_estimate_tool_tokens_covers_known_tools(): """Should include schemas for well-known tools like terminal, web_search.""" import hermes_cli.tools_config as tc tc._tool_token_cache = None tokens = tc._estimate_tool_tokens() # These tools should always be discoverable for expected in ("terminal", "web_search", "read_file"): assert expected in tokens, f"Expected {expected!r} in token estimates" # ─── Status Function Tests ─────────────────────────────────────────────────── def test_prompt_toolset_checklist_passes_status_fn(monkeypatch): """_prompt_toolset_checklist should pass a status_fn to curses_checklist.""" import hermes_cli.tools_config as tc captured_kwargs = {} def fake_checklist(title, items, selected, *, cancel_returns=None, status_fn=None): captured_kwargs["status_fn"] = status_fn captured_kwargs["title"] = title return selected # Return pre-selected unchanged monkeypatch.setattr("hermes_cli.curses_ui.curses_checklist", fake_checklist) tc._prompt_toolset_checklist("CLI", {"web", "terminal"}) assert "status_fn" in captured_kwargs # If tiktoken is available, status_fn should be set tokens = tc._estimate_tool_tokens() if tokens: assert captured_kwargs["status_fn"] is not None def test_status_fn_returns_formatted_token_count(monkeypatch): """The status_fn should return a human-readable token count string.""" import hermes_cli.tools_config as tc from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS captured = {} def fake_checklist(title, items, selected, *, cancel_returns=None, status_fn=None): captured["status_fn"] = status_fn return selected monkeypatch.setattr("hermes_cli.curses_ui.curses_checklist", fake_checklist) tc._prompt_toolset_checklist("CLI", {"web", "terminal"}) status_fn = captured.get("status_fn") if status_fn is None: pytest.skip("tiktoken unavailable; status_fn not created") # Find the indices for web and terminal idx_map = {ts_key: i for i, (ts_key, _, _) in enumerate(CONFIGURABLE_TOOLSETS)} # Call status_fn with web + terminal selected result = status_fn({idx_map["web"], idx_map["terminal"]}) assert "tokens" in result assert "Est. tool context" in result def test_status_fn_deduplicates_overlapping_tools(monkeypatch): """When toolsets overlap (browser includes web_search), tokens should not double-count.""" import hermes_cli.tools_config as tc from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS captured = {} def fake_checklist(title, items, selected, *, cancel_returns=None, status_fn=None): captured["status_fn"] = status_fn return selected monkeypatch.setattr("hermes_cli.curses_ui.curses_checklist", fake_checklist) tc._prompt_toolset_checklist("CLI", {"web"}) status_fn = captured.get("status_fn") if status_fn is None: pytest.skip("tiktoken unavailable; status_fn not created") idx_map = {ts_key: i for i, (ts_key, _, _) in enumerate(CONFIGURABLE_TOOLSETS)} # web alone web_only = status_fn({idx_map["web"]}) # browser includes web_search, so browser + web should not double-count web_search browser_only = status_fn({idx_map["browser"]}) both = status_fn({idx_map["web"], idx_map["browser"]}) # Extract numeric token counts from strings like "~8.3k tokens" or "~350 tokens" import re def parse_tokens(s): m = re.search(r"~([\d.]+)k?\s+tokens", s) if not m: return 0 val = float(m.group(1)) if "k" in s[m.start():m.end()]: val *= 1000 return val web_tok = parse_tokens(web_only) browser_tok = parse_tokens(browser_only) both_tok = parse_tokens(both) # Both together should be LESS than naive sum (due to web_search dedup) naive_sum = web_tok + browser_tok assert both_tok < naive_sum, ( f"Expected deduplication: web({web_tok}) + browser({browser_tok}) = {naive_sum} " f"but combined = {both_tok}" ) def test_status_fn_empty_selection(): """Status function with no tools selected should return ~0 tokens.""" import hermes_cli.tools_config as tc tc._tool_token_cache = None tokens = tc._estimate_tool_tokens() if not tokens: pytest.skip("tiktoken unavailable") from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS from toolsets import resolve_toolset ts_keys = [ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS] def status_fn(chosen: set) -> str: all_tools: set = set() for idx in chosen: all_tools.update(resolve_toolset(ts_keys[idx])) total = sum(tokens.get(name, 0) for name in all_tools) if total >= 1000: return f"Est. tool context: ~{total / 1000:.1f}k tokens" return f"Est. tool context: ~{total} tokens" result = status_fn(set()) assert "~0 tokens" in result # ─── Curses UI Status Bar Tests ────────────────────────────────────────────── def test_curses_checklist_numbered_fallback_shows_status(monkeypatch, capsys): """The numbered fallback should print the status_fn output.""" from hermes_cli.curses_ui import _numbered_fallback def my_status(chosen): return f"Selected {len(chosen)} items" # Simulate user pressing Enter immediately (empty input → confirm) monkeypatch.setattr("builtins.input", lambda _prompt="": "") result = _numbered_fallback( "Test title", ["Item A", "Item B", "Item C"], {0, 2}, {0, 2}, status_fn=my_status, ) captured = capsys.readouterr() assert "Selected 2 items" in captured.out assert result == {0, 2} def test_curses_checklist_numbered_fallback_without_status(monkeypatch, capsys): """The numbered fallback should work fine without status_fn.""" from hermes_cli.curses_ui import _numbered_fallback monkeypatch.setattr("builtins.input", lambda _prompt="": "") result = _numbered_fallback( "Test title", ["Item A", "Item B"], {0}, {0}, ) captured = capsys.readouterr() assert "Est. tool context" not in captured.out assert result == {0} # ─── Registry get_schema Tests ─────────────────────────────────────────────── def test_registry_get_schema_returns_schema(): """registry.get_schema() should return a tool's schema dict.""" from tools.registry import registry # Import to trigger discovery import model_tools # noqa: F401 schema = registry.get_schema("terminal") assert schema is not None assert "name" in schema assert schema["name"] == "terminal" assert "parameters" in schema def test_registry_get_schema_returns_none_for_unknown(): """registry.get_schema() should return None for unknown tools.""" from tools.registry import registry assert registry.get_schema("nonexistent_tool_xyz") is None