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>
155 lines
5.0 KiB
Python
155 lines
5.0 KiB
Python
"""Integration test: LSP plugin skips non-local paths.
|
|
|
|
The host-side LSP server can't see files inside Docker/Modal/SSH
|
|
sandboxes. The plugin's ``_pre_tool_call`` uses ``os.path.exists``
|
|
on the parent directory as a heuristic local-only gate. These tests
|
|
verify the plugin hooks skip when the path clearly doesn't exist on
|
|
the host filesystem.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_plugin_state():
|
|
"""Reset plugin module state between tests."""
|
|
# Import the plugin and clear any service state
|
|
from plugins.lsp import _baselines
|
|
_baselines.clear()
|
|
yield
|
|
_baselines.clear()
|
|
|
|
|
|
def test_pre_tool_call_skips_nonexistent_parent_dir():
|
|
"""pre_tool_call returns early when the path's parent dir doesn't exist (Docker/SSH heuristic)."""
|
|
from plugins import lsp as lsp_plugin
|
|
|
|
# Simulate a path that doesn't exist on host (e.g., inside Docker)
|
|
fake_path = "/nonexistent-docker-container-fs/app/main.py"
|
|
|
|
# Mock _ensure_service to return a mock service
|
|
mock_service = type("MockService", (), {
|
|
"is_active": lambda self: True,
|
|
"enabled_for": lambda self, p: True,
|
|
"snapshot_baseline": lambda self, p: None,
|
|
})()
|
|
|
|
with patch.object(lsp_plugin, "_service", mock_service):
|
|
lsp_plugin._pre_tool_call(
|
|
tool_name="write_file",
|
|
args={"path": fake_path},
|
|
session_id="test-session",
|
|
tool_call_id="call-1",
|
|
)
|
|
|
|
# Baseline should NOT be captured because parent dir doesn't exist
|
|
assert ("test-session", os.path.normpath(fake_path)) not in lsp_plugin._baselines
|
|
|
|
|
|
def test_pre_tool_call_proceeds_for_local_path(tmp_path):
|
|
"""pre_tool_call captures baseline when path exists locally."""
|
|
from plugins import lsp as lsp_plugin
|
|
|
|
# Create a real file so the parent-dir check passes
|
|
test_file = tmp_path / "test.py"
|
|
test_file.write_text("x = 1\n")
|
|
|
|
mock_service = type("MockService", (), {
|
|
"is_active": lambda self: True,
|
|
"enabled_for": lambda self, p: True,
|
|
"snapshot_baseline": lambda self, p: None,
|
|
})()
|
|
|
|
with patch.object(lsp_plugin, "_service", mock_service):
|
|
lsp_plugin._pre_tool_call(
|
|
tool_name="write_file",
|
|
args={"path": str(test_file)},
|
|
session_id="test-session",
|
|
tool_call_id="call-2",
|
|
)
|
|
|
|
# Baseline SHOULD be captured because the local path exists
|
|
assert ("test-session", str(test_file)) in lsp_plugin._baselines
|
|
|
|
|
|
def test_pre_tool_call_skips_non_write_tools():
|
|
"""pre_tool_call is a no-op for tools other than write_file/patch."""
|
|
from plugins import lsp as lsp_plugin
|
|
|
|
lsp_plugin._pre_tool_call(
|
|
tool_name="terminal",
|
|
args={"command": "ls"},
|
|
session_id="test-session",
|
|
tool_call_id="call-3",
|
|
)
|
|
|
|
assert len(lsp_plugin._baselines) == 0
|
|
|
|
|
|
def test_pre_tool_call_skips_v4a_patch():
|
|
"""pre_tool_call skips V4A multi-file patches (has 'patch' key, no 'path' key)."""
|
|
from plugins import lsp as lsp_plugin
|
|
|
|
mock_service = type("MockService", (), {
|
|
"is_active": lambda self: True,
|
|
"enabled_for": lambda self, p: True,
|
|
"snapshot_baseline": lambda self, p: None,
|
|
})()
|
|
|
|
with patch.object(lsp_plugin, "_service", mock_service):
|
|
lsp_plugin._pre_tool_call(
|
|
tool_name="patch",
|
|
args={"patch": "*** Begin Patch\n*** Update File: foo.py\n..."},
|
|
session_id="test-session",
|
|
tool_call_id="call-4",
|
|
)
|
|
|
|
assert len(lsp_plugin._baselines) == 0
|
|
|
|
|
|
def test_transform_tool_result_injects_diagnostics(tmp_path):
|
|
"""transform_tool_result appends lsp_diagnostics field to JSON result."""
|
|
from plugins import lsp as lsp_plugin
|
|
|
|
test_file = tmp_path / "test.py"
|
|
abs_path = str(test_file)
|
|
|
|
# Pre-populate a baseline entry (simulating pre_tool_call ran)
|
|
lsp_plugin._baselines[("test-session", abs_path)] = []
|
|
|
|
# Mock service that returns a diagnostic
|
|
mock_service = type("MockService", (), {
|
|
"is_active": lambda self: True,
|
|
"enabled_for": lambda self, p: True,
|
|
"get_diagnostics_sync": lambda self, p, delta=True, timeout=3.0: [
|
|
{
|
|
"severity": 1,
|
|
"range": {"start": {"line": 1, "character": 4}},
|
|
"message": "Type error: str is not int",
|
|
"code": "reportReturnType",
|
|
"source": "Pyright",
|
|
}
|
|
],
|
|
})()
|
|
|
|
with patch.object(lsp_plugin, "_service", mock_service):
|
|
result = lsp_plugin._transform_tool_result(
|
|
tool_name="write_file",
|
|
args={"path": abs_path},
|
|
result='{"bytes_written": 42, "dirs_created": false}',
|
|
session_id="test-session",
|
|
tool_call_id="call-5",
|
|
)
|
|
|
|
assert result is not None
|
|
import json
|
|
data = json.loads(result)
|
|
assert "lsp_diagnostics" in data
|
|
assert "reportReturnType" in data["lsp_diagnostics"]
|
|
assert "bytes_written" in data # Original fields preserved
|