mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
23344a9a3c
Ship LSP semantic diagnostics as a bundled plugin (plugins/lsp/) using existing hook system. Zero lines of core code modified. Plugin wiring: - pre_tool_call: capture LSP baseline before write_file/patch - transform_tool_result: inject diagnostics into tool result JSON - on_session_start/on_session_end + atexit: lifecycle management Key design: - Baselines keyed by (session_id, abs_path) for concurrent safety - Diagnostics added as 'lsp_diagnostics' JSON field (preserves shape) - Per-file workspace detection (no static session-start gate) - V4A multi-file patch skipped for MVP - Short timeout (3s) — cold start degrades gracefully - os.path.exists heuristic for Docker/SSH backend skip - First relevant write with no server → INFO log with install hint Tests: 77/77 pass including: - Protocol framing, reporter formatting, workspace resolution - Client E2E against mock LSP server (live_system_guard_bypass) - Eventlog steady-state silence contract - Backend-gate heuristic (local vs non-local paths) - Full hook flow integration (pre→write→transform with diagnostics) Source: PR #24168 by @teknium1, PR #24155 by @OutThisLife Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
204 lines
6.6 KiB
Python
204 lines
6.6 KiB
Python
"""Integration test: full hook flow pre_tool_call → write → transform_tool_result.
|
|
|
|
Verifies that the plugin hook wiring correctly:
|
|
1. Captures a baseline in pre_tool_call
|
|
2. Passes through a write (no interference)
|
|
3. Injects diagnostics in transform_tool_result
|
|
|
|
Uses a mocked LSP service to avoid requiring pyright/gopls in CI.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate():
|
|
"""Clear plugin state between tests."""
|
|
from plugins import lsp as lsp_plugin
|
|
lsp_plugin._baselines.clear()
|
|
old_service = lsp_plugin._service
|
|
yield
|
|
lsp_plugin._baselines.clear()
|
|
lsp_plugin._service = old_service
|
|
|
|
|
|
class FakeLSPService:
|
|
"""Minimal LSP service mock that returns canned diagnostics."""
|
|
|
|
def __init__(self, diagnostics=None):
|
|
self._diagnostics = diagnostics or []
|
|
|
|
def is_active(self):
|
|
return True
|
|
|
|
def enabled_for(self, path):
|
|
return path.endswith(".py") or path.endswith(".ts")
|
|
|
|
def snapshot_baseline(self, path):
|
|
pass # no-op, just marks that we visited
|
|
|
|
def get_diagnostics_sync(self, path, delta=True, timeout=3.0):
|
|
return self._diagnostics
|
|
|
|
def shutdown(self):
|
|
pass
|
|
|
|
|
|
def test_full_hook_flow_produces_diagnostics(tmp_path):
|
|
"""Exercise pre_tool_call → (write) → transform_tool_result end-to-end."""
|
|
from plugins import lsp as lsp_plugin
|
|
|
|
test_file = tmp_path / "broken.py"
|
|
test_file.write_text("x: int = 'oops'\n")
|
|
abs_path = str(test_file)
|
|
|
|
fake_service = FakeLSPService(diagnostics=[
|
|
{
|
|
"severity": 1,
|
|
"range": {"start": {"line": 0, "character": 9}},
|
|
"message": 'Expression of type "str" is incompatible with declared type "int"',
|
|
"code": "reportAssignmentType",
|
|
"source": "Pyright",
|
|
}
|
|
])
|
|
|
|
with patch.object(lsp_plugin, "_service", fake_service):
|
|
# Step 1: pre_tool_call captures baseline
|
|
lsp_plugin._pre_tool_call(
|
|
tool_name="write_file",
|
|
args={"path": abs_path, "content": "x: int = 'oops'\n"},
|
|
session_id="test-session",
|
|
tool_call_id="call-001",
|
|
)
|
|
assert ("test-session", abs_path) in lsp_plugin._baselines
|
|
|
|
# Step 2: simulate the write completing (tool output)
|
|
tool_result = json.dumps({
|
|
"bytes_written": 16,
|
|
"dirs_created": False,
|
|
"lint": None,
|
|
})
|
|
|
|
# Step 3: transform_tool_result injects diagnostics
|
|
transformed = lsp_plugin._transform_tool_result(
|
|
tool_name="write_file",
|
|
args={"path": abs_path, "content": "x: int = 'oops'\n"},
|
|
result=tool_result,
|
|
session_id="test-session",
|
|
tool_call_id="call-001",
|
|
)
|
|
|
|
# Verify: result is valid JSON with lsp_diagnostics field
|
|
assert transformed is not None
|
|
data = json.loads(transformed)
|
|
assert "lsp_diagnostics" in data
|
|
assert "reportAssignmentType" in data["lsp_diagnostics"]
|
|
assert "Pyright" in data["lsp_diagnostics"]
|
|
# Original fields preserved
|
|
assert data["bytes_written"] == 16
|
|
assert data["dirs_created"] is False
|
|
|
|
# Baseline consumed (removed after use)
|
|
assert ("test-session", abs_path) not in lsp_plugin._baselines
|
|
|
|
|
|
def test_hook_flow_returns_none_when_no_diagnostics(tmp_path):
|
|
"""transform_tool_result returns None (no modification) when LSP is clean."""
|
|
from plugins import lsp as lsp_plugin
|
|
|
|
test_file = tmp_path / "clean.py"
|
|
test_file.write_text("x: int = 42\n")
|
|
abs_path = str(test_file)
|
|
|
|
fake_service = FakeLSPService(diagnostics=[]) # Clean — no errors
|
|
|
|
with patch.object(lsp_plugin, "_service", fake_service):
|
|
lsp_plugin._pre_tool_call(
|
|
tool_name="write_file",
|
|
args={"path": abs_path, "content": "x: int = 42\n"},
|
|
session_id="test-session",
|
|
tool_call_id="call-002",
|
|
)
|
|
|
|
transformed = lsp_plugin._transform_tool_result(
|
|
tool_name="write_file",
|
|
args={"path": abs_path, "content": "x: int = 42\n"},
|
|
result='{"bytes_written": 12}',
|
|
session_id="test-session",
|
|
tool_call_id="call-002",
|
|
)
|
|
|
|
# No diagnostics → return None → result unchanged
|
|
assert transformed is None
|
|
|
|
|
|
def test_hook_flow_no_baseline_means_no_injection(tmp_path):
|
|
"""transform_tool_result does nothing if pre_tool_call didn't fire."""
|
|
from plugins import lsp as lsp_plugin
|
|
|
|
test_file = tmp_path / "no_baseline.py"
|
|
abs_path = str(test_file)
|
|
|
|
fake_service = FakeLSPService(diagnostics=[
|
|
{"severity": 1, "range": {"start": {"line": 0, "character": 0}},
|
|
"message": "error", "code": "E1", "source": "test"}
|
|
])
|
|
|
|
with patch.object(lsp_plugin, "_service", fake_service):
|
|
# Skip pre_tool_call — simulate a case where it didn't fire
|
|
transformed = lsp_plugin._transform_tool_result(
|
|
tool_name="write_file",
|
|
args={"path": abs_path},
|
|
result='{"bytes_written": 5}',
|
|
session_id="test-session",
|
|
tool_call_id="call-003",
|
|
)
|
|
|
|
# No baseline was captured, so no injection
|
|
assert transformed is None
|
|
|
|
|
|
def test_hook_flow_patch_tool(tmp_path):
|
|
"""Hook flow works for patch tool (single-path mode)."""
|
|
from plugins import lsp as lsp_plugin
|
|
|
|
test_file = tmp_path / "patched.py"
|
|
test_file.write_text("def f() -> int:\n return 'wrong'\n")
|
|
abs_path = str(test_file)
|
|
|
|
fake_service = FakeLSPService(diagnostics=[
|
|
{
|
|
"severity": 1,
|
|
"range": {"start": {"line": 1, "character": 11}},
|
|
"message": 'Cannot return "str" from function with return type "int"',
|
|
"code": "reportReturnType",
|
|
"source": "Pyright",
|
|
}
|
|
])
|
|
|
|
with patch.object(lsp_plugin, "_service", fake_service):
|
|
lsp_plugin._pre_tool_call(
|
|
tool_name="patch",
|
|
args={"path": abs_path, "old_string": "return 42", "new_string": "return 'wrong'"},
|
|
session_id="test-session",
|
|
tool_call_id="call-004",
|
|
)
|
|
|
|
transformed = lsp_plugin._transform_tool_result(
|
|
tool_name="patch",
|
|
args={"path": abs_path, "old_string": "return 42", "new_string": "return 'wrong'"},
|
|
result='{"success": true, "diff": "..."}',
|
|
session_id="test-session",
|
|
tool_call_id="call-004",
|
|
)
|
|
|
|
assert transformed is not None
|
|
data = json.loads(transformed)
|
|
assert "lsp_diagnostics" in data
|
|
assert "reportReturnType" in data["lsp_diagnostics"]
|