/ tests / test_transform_tool_result_hook.py
test_transform_tool_result_hook.py
  1  """Tests for the ``transform_tool_result`` plugin hook wired into
  2  ``model_tools.handle_function_call``.
  3  
  4  Mirrors the ``transform_terminal_output`` hook tests from Phase 1 but
  5  targets the generic tool-result seam that runs for every tool dispatch.
  6  """
  7  
  8  import json
  9  import os
 10  from pathlib import Path
 11  from unittest.mock import MagicMock
 12  
 13  import hermes_cli.plugins as plugins_mod
 14  import model_tools
 15  
 16  
 17  _UNSET = object()
 18  
 19  
 20  def _run_handle_function_call(
 21      monkeypatch,
 22      *,
 23      tool_name="dummy_tool",
 24      tool_args=None,
 25      dispatch_result='{"output": "original"}',
 26      invoke_hook=_UNSET,
 27  ):
 28      """Drive ``handle_function_call`` with a mocked registry dispatch."""
 29      from tools.registry import registry
 30  
 31      monkeypatch.setattr(
 32          registry, "dispatch",
 33          lambda name, args, **kw: dispatch_result,
 34      )
 35      # Skip unrelated side effects (read-loop tracker).
 36      monkeypatch.setattr(model_tools, "_READ_SEARCH_TOOLS", frozenset())
 37  
 38      if invoke_hook is not _UNSET:
 39          # Patch the symbol actually imported inside handle_function_call.
 40          monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
 41  
 42      return model_tools.handle_function_call(
 43          tool_name,
 44          tool_args or {},
 45          task_id="t1",
 46          session_id="s1",
 47          tool_call_id="tc1",
 48          skip_pre_tool_call_hook=True,
 49      )
 50  
 51  
 52  def test_result_unchanged_when_no_hook_registered(monkeypatch):
 53      # Real invoke_hook with no plugins loaded returns [].
 54      monkeypatch.setenv("HERMES_HOME", "/tmp/hermes_no_plugins")
 55      # Force a fresh plugin manager so no stale plugins pollute state.
 56      plugins_mod._plugin_manager = plugins_mod.PluginManager()
 57  
 58      out = _run_handle_function_call(monkeypatch)
 59      assert out == '{"output": "original"}'
 60  
 61  
 62  def test_result_unchanged_for_none_hook_return(monkeypatch):
 63      out = _run_handle_function_call(
 64          monkeypatch,
 65          invoke_hook=lambda hook_name, **kw: [None],
 66      )
 67      assert out == '{"output": "original"}'
 68  
 69  
 70  def test_result_ignores_non_string_hook_returns(monkeypatch):
 71      out = _run_handle_function_call(
 72          monkeypatch,
 73          invoke_hook=lambda hook_name, **kw: [{"bad": True}, 123, ["nope"]],
 74      )
 75      assert out == '{"output": "original"}'
 76  
 77  
 78  def test_first_valid_string_return_replaces_result(monkeypatch):
 79      out = _run_handle_function_call(
 80          monkeypatch,
 81          invoke_hook=lambda hook_name, **kw: [None, {"x": 1}, "first", "second"],
 82      )
 83      assert out == "first"
 84  
 85  
 86  def test_hook_receives_expected_kwargs(monkeypatch):
 87      captured = {}
 88  
 89      def _hook(hook_name, **kwargs):
 90          if hook_name == "transform_tool_result":
 91              captured.update(kwargs)
 92          return []
 93  
 94      out = _run_handle_function_call(
 95          monkeypatch,
 96          tool_name="my_tool",
 97          tool_args={"a": 1, "b": "x"},
 98          dispatch_result='{"ok": true}',
 99          invoke_hook=_hook,
100      )
101      assert out == '{"ok": true}'
102      assert captured["tool_name"] == "my_tool"
103      assert captured["args"] == {"a": 1, "b": "x"}
104      assert captured["result"] == '{"ok": true}'
105      assert captured["task_id"] == "t1"
106      assert captured["session_id"] == "s1"
107      assert captured["tool_call_id"] == "tc1"
108  
109  
110  def test_hook_exception_falls_back_to_original(monkeypatch):
111      def _raise(*_a, **_kw):
112          raise RuntimeError("boom")
113  
114      out = _run_handle_function_call(
115          monkeypatch,
116          invoke_hook=_raise,
117      )
118      assert out == '{"output": "original"}'
119  
120  
121  def test_post_tool_call_remains_observational(monkeypatch):
122      """post_tool_call return values must NOT replace the result."""
123      def _hook(hook_name, **kw):
124          if hook_name == "post_tool_call":
125              # Observers returning a string must be ignored.
126              return ["observer return should be ignored"]
127          return []
128  
129      out = _run_handle_function_call(
130          monkeypatch,
131          invoke_hook=_hook,
132      )
133      assert out == '{"output": "original"}'
134  
135  
136  def test_transform_tool_result_runs_after_post_tool_call(monkeypatch):
137      """post_tool_call sees ORIGINAL result; transform_tool_result sees same and may replace."""
138      observed = []
139  
140      def _hook(hook_name, **kw):
141          if hook_name == "post_tool_call":
142              observed.append(("post_tool_call", kw["result"]))
143              return []
144          if hook_name == "transform_tool_result":
145              observed.append(("transform_tool_result", kw["result"]))
146              return ["rewritten"]
147          return []
148  
149      out = _run_handle_function_call(
150          monkeypatch,
151          dispatch_result='{"raw": "value"}',
152          invoke_hook=_hook,
153      )
154      assert out == "rewritten"
155      # Both hooks saw the ORIGINAL (untransformed) result.
156      assert observed == [
157          ("post_tool_call", '{"raw": "value"}'),
158          ("transform_tool_result", '{"raw": "value"}'),
159      ]
160  
161  
162  def test_transform_tool_result_integration_with_real_plugin(monkeypatch, tmp_path):
163      """End-to-end: load a real plugin from HERMES_HOME and verify it rewrites results."""
164      import yaml
165  
166      hermes_home = Path(os.environ["HERMES_HOME"])
167      plugins_dir = hermes_home / "plugins"
168      plugin_dir = plugins_dir / "transform_result_canon"
169      plugin_dir.mkdir(parents=True)
170      (plugin_dir / "plugin.yaml").write_text("name: transform_result_canon\n", encoding="utf-8")
171      (plugin_dir / "__init__.py").write_text(
172          "def register(ctx):\n"
173          '    ctx.register_hook("transform_tool_result", '
174          'lambda **kw: f\'CANON[{kw["tool_name"]}]\' + kw["result"])\n',
175          encoding="utf-8",
176      )
177      # Plugins are opt-in — must be listed in plugins.enabled to load.
178      cfg_path = hermes_home / "config.yaml"
179      cfg_path.write_text(
180          yaml.safe_dump({"plugins": {"enabled": ["transform_result_canon"]}}),
181          encoding="utf-8",
182      )
183  
184      # Force a fresh plugin manager so the new config is picked up.
185      plugins_mod._plugin_manager = plugins_mod.PluginManager()
186      plugins_mod.discover_plugins()
187  
188      out = _run_handle_function_call(
189          monkeypatch,
190          tool_name="some_tool",
191          dispatch_result='{"payload": 42}',
192      )
193      assert out == 'CANON[some_tool]{"payload": 42}'