"""Measured-work pins for the slash-completer config reads. The /tools and /personality completers run on every keystroke while the user types those commands (complete_while_typing). They used to re-read + re-parse the full config on every keypress: load_config()'s defensive deepcopy (~345us tax) in _tools_completions, and load_cli_config()'s full YAML parse + defaults deep-merge (~110us) in _personality_completions. These pins hold the per-keystroke cost down: - _tools_completions uses the read-only loader (no deepcopy). - _personality_completions memoises the personalities source keyed on the config file's mtime, so the parse+merge runs once per config state. """ from __future__ import annotations import os from unittest.mock import patch import pytest import hermes_cli.commands as commands_mod def _reset_personalities_memo(): commands_mod._personalities_memo = None @pytest.fixture(autouse=True) def _reset_memo(): _reset_personalities_memo() yield _reset_personalities_memo() class TestToolsCompletionsReadonlyConfig: def test_uses_readonly_loader(self): """_tools_completions must not pay the defensive deepcopy. The completer only reads the config (toolset enable state + MCP server names). Using load_config_readonly() skips the ~345us deepcopy that load_config() applies on every cache hit — a per-keystroke cost while completing /tools enable|disable. """ calls = {"deepcopy": 0, "readonly": 0} def counting_deepcopy(*a, **k): calls["deepcopy"] += 1 return {} def counting_readonly(*a, **k): calls["readonly"] += 1 return {} # The completer imports the loader inside the function, so patch the # source module. Portable-MCP lookup is stubbed because it triggers # one-time plugin discovery (which legitimately calls load_config # during process init) — this test asserts on the completer's own # per-keystroke reads, not discovery's one-off startup reads. with patch("hermes_cli.config.load_config", counting_deepcopy), \ patch("hermes_cli.config.load_config_readonly", counting_readonly), \ patch("hermes_cli.plugins.get_portable_mcp_server_names_nowait", lambda: set()), \ patch("hermes_cli.tools_config._get_plugin_toolset_keys", lambda: set()), \ patch("hermes_cli.tools_config._homeassistant_credentials_present", lambda: False), \ patch("hermes_cli.tools_config._xai_credentials_present", lambda: False): list(commands_mod.SlashCommandCompleter._tools_completions("enable ", "enable ")) assert calls["readonly"] == 1, "completer should use the readonly loader" assert calls["deepcopy"] == 0, ( "completer must not call the deepcopy loader on a read-only path" ) class TestPersonalityCompletionsMemo: def test_load_cli_config_called_once_per_config_state(self, monkeypatch): """The /personality completer parses the config once per state. load_cli_config() does a full YAML parse + deep merge of the built-in defaults; the completer runs on every keystroke. The mtime-keyed memo keeps that parse to once per config change. """ calls = {"n": 0} def counting_load_cli_config(): calls["n"] += 1 return { "agent": { "personalities": { "helpful": "You are helpful.", "concise": "You are concise.", } } } monkeypatch.setattr(commands_mod, "_personalities_memo", None) with patch("cli.load_cli_config", counting_load_cli_config): # First call: cache miss -> one parse. list(commands_mod.SlashCommandCompleter._personality_completions("hel", "hel")) assert calls["n"] == 1, "first call should parse once" # Subsequent keystrokes: cache hit -> no re-parse. for _ in range(10): list(commands_mod.SlashCommandCompleter._personality_completions("hel", "hel")) assert calls["n"] == 1, ( "repeated keystrokes must reuse the memoised personalities, " f"got {calls['n']} parses" ) def test_mtime_change_reparses(self, monkeypatch, tmp_path): """A config file change on disk invalidates the memo.""" from pathlib import Path cfg_path = tmp_path / "config.yaml" cfg_path.write_text( "agent:\n personalities:\n helpful: \"v1\"\n", encoding="utf-8", ) # Pin an explicit mtime so the change below is a guaranteed mtime_ns # bump regardless of filesystem timestamp granularity. os.utime(cfg_path, (1_700_000_000, 1_700_000_000)) calls = {"n": 0} def counting_load_cli_config(): calls["n"] += 1 return {"agent": {"personalities": {"helpful": "v1"}}} def fake_config_path(): return cfg_path monkeypatch.setattr(commands_mod, "_personalities_memo", None) with patch("cli.load_cli_config", counting_load_cli_config), \ patch("hermes_cli.config.get_config_path", fake_config_path): list(commands_mod.SlashCommandCompleter._personality_completions("h", "h")) assert calls["n"] == 1 # Bump the file mtime -> memo invalidates -> re-parse once. os.utime(cfg_path, (1_800_000_000, 1_800_000_000)) list(commands_mod.SlashCommandCompleter._personality_completions("h", "h")) assert calls["n"] == 2, "config mtime change should re-parse once"