Files
hermes-agent/tests/plugins/lsp/test_backend_gate.py
T
alt-glitch 23344a9a3c feat(lsp): plugin-based LSP diagnostics with zero core changes
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>
2026-05-12 13:01:13 +00:00

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