Compare commits

...

1 Commits

Author SHA1 Message Date
freemo de025398a1 fix(lsp): implement missing LSP resource handler modules
Implement the three LSP resource handler classes that were referenced
in the resource registry but missing from the codebase, causing
ImportError at runtime for any lsp-server, lsp-workspace, or
lsp-document resource type.

Changes:
- Add src/cleveragents/resource/handlers/lsp.py with fully
  statically-typed implementations of LSPServerHandler,
  LSPWorkspaceHandler, and LSPDocumentHandler, each extending
  BaseResourceHandler with sandbox_strategy=none
- LSPServerHandler: read returns server config summary; write returns
  not-supported; discover_children returns empty list
- LSPWorkspaceHandler: read returns workspace root summary; write
  returns not-supported; discover_children returns empty list
- LSPDocumentHandler: read returns document URI summary; write accepts
  document content and returns success WriteResult
- Export all three handler classes from handlers/__init__.py
- Add Behave BDD scenarios (TDD: failing test written first) covering
  import, inheritance, type labels, __init__ exports, handler
  resolution, and CRUD operations
- Add Robot Framework integration tests covering all handler classes
  and registry resolution

All nox quality gates pass: lint, typecheck, unit_tests (22 scenarios,
58 steps), integration tests (8 tests).

ISSUES CLOSED: #2913
2026-04-05 10:25:02 +00:00
6 changed files with 1217 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
@m7 @resource_handler @lsp @bug_2913
Feature: LSP resource handler module implementation
As a developer using LSP resource types
I want the LSP handler module to exist and be importable
So that lsp-server, lsp-workspace, and lsp-document resources work at runtime
# TDD: import guard (must fail before implementation)
Scenario: LSPServerHandler is importable from the lsp handler module
When I import LSPServerHandler from cleveragents.resource.handlers.lsp
Then the import should succeed
Scenario: LSPWorkspaceHandler is importable from the lsp handler module
When I import LSPWorkspaceHandler from cleveragents.resource.handlers.lsp
Then the import should succeed
Scenario: LSPDocumentHandler is importable from the lsp handler module
When I import LSPDocumentHandler from cleveragents.resource.handlers.lsp
Then the import should succeed
# ── Handler class structure ───────────────────────────────────
Scenario: LSPServerHandler extends BaseResourceHandler
When I import LSPServerHandler from cleveragents.resource.handlers.lsp
Then LSPServerHandler should extend BaseResourceHandler
Scenario: LSPWorkspaceHandler extends BaseResourceHandler
When I import LSPWorkspaceHandler from cleveragents.resource.handlers.lsp
Then LSPWorkspaceHandler should extend BaseResourceHandler
Scenario: LSPDocumentHandler extends BaseResourceHandler
When I import LSPDocumentHandler from cleveragents.resource.handlers.lsp
Then LSPDocumentHandler should extend BaseResourceHandler
# ── Handler type labels ───────────────────────────────────────
Scenario: LSPServerHandler has correct type label
When I import LSPServerHandler from cleveragents.resource.handlers.lsp
Then LSPServerHandler._type_label should equal "lsp-server"
Scenario: LSPWorkspaceHandler has correct type label
When I import LSPWorkspaceHandler from cleveragents.resource.handlers.lsp
Then LSPWorkspaceHandler._type_label should equal "lsp-workspace"
Scenario: LSPDocumentHandler has correct type label
When I import LSPDocumentHandler from cleveragents.resource.handlers.lsp
Then LSPDocumentHandler._type_label should equal "lsp-document"
# ── __init__.py exports ───────────────────────────────────────
Scenario: LSPServerHandler is exported from handlers __init__
When I import LSPServerHandler from cleveragents.resource.handlers
Then the import should succeed
Scenario: LSPWorkspaceHandler is exported from handlers __init__
When I import LSPWorkspaceHandler from cleveragents.resource.handlers
Then the import should succeed
Scenario: LSPDocumentHandler is exported from handlers __init__
When I import LSPDocumentHandler from cleveragents.resource.handlers
Then the import should succeed
# ── Registry handler references resolve ──────────────────────
Scenario: lsp-server handler reference resolves without ImportError
When I resolve the handler for resource type "lsp-server"
Then the handler resolution should succeed
And the resolved handler should be an instance of LSPServerHandler
Scenario: lsp-workspace handler reference resolves without ImportError
When I resolve the handler for resource type "lsp-workspace"
Then the handler resolution should succeed
And the resolved handler should be an instance of LSPWorkspaceHandler
Scenario: lsp-document handler reference resolves without ImportError
When I resolve the handler for resource type "lsp-document"
Then the handler resolution should succeed
And the resolved handler should be an instance of LSPDocumentHandler
# ── LSPServerHandler lifecycle methods ───────────────────────
Scenario: LSPServerHandler read returns content summary
Given an LSP server resource with location "/usr/bin/pylsp"
When I call lsp read on the LSPServerHandler
Then the read result should be a Content instance
And the read result data should contain "lsp-server"
Scenario: LSPServerHandler write is not supported
Given an LSP server resource with location "/usr/bin/pylsp"
When I call lsp write on the LSPServerHandler with data b"test"
Then the write result should indicate not supported
Scenario: LSPServerHandler discover_children returns empty list
Given an LSP server resource with location "/usr/bin/pylsp"
When I call lsp discover_children on the LSPServerHandler
Then the discover_children result should be a list
# ── LSPWorkspaceHandler methods ───────────────────────────────
Scenario: LSPWorkspaceHandler read returns workspace summary
Given an LSP workspace resource with location "/workspace/myproject"
When I call lsp read on the LSPWorkspaceHandler
Then the read result should be a Content instance
And the read result data should contain "lsp-workspace"
Scenario: LSPWorkspaceHandler discover_children returns list
Given an LSP workspace resource with location "/workspace/myproject"
When I call lsp discover_children on the LSPWorkspaceHandler
Then the discover_children result should be a list
# ── LSPDocumentHandler methods ────────────────────────────────
Scenario: LSPDocumentHandler read returns document summary
Given an LSP document resource with location "/workspace/myproject/main.py"
When I call lsp read on the LSPDocumentHandler
Then the read result should be a Content instance
And the read result data should contain "lsp-document"
Scenario: LSPDocumentHandler write returns success result
Given an LSP document resource with location "/workspace/myproject/main.py"
When I call lsp write on the LSPDocumentHandler with data b"print('hello')"
Then the write result should be a WriteResult instance
And the write result success should be true
@@ -0,0 +1,295 @@
"""Step definitions for lsp_resource_handlers.feature.
TDD steps for issue #2913 — LSP resource handler module missing.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when
# ── Import steps ─────────────────────────────────────────────
@when("I import {class_name} from cleveragents.resource.handlers.lsp")
def step_import_from_lsp_module(context: Any, class_name: str) -> None:
"""Attempt to import a class from the lsp handler module."""
context.lsp_handler_import_error = None
context.lsp_handler_class = None
try:
import importlib
mod = importlib.import_module("cleveragents.resource.handlers.lsp")
context.lsp_handler_class = getattr(mod, class_name)
except (ImportError, ModuleNotFoundError, AttributeError) as exc:
context.lsp_handler_import_error = exc
@when("I import {class_name} from cleveragents.resource.handlers")
def step_import_from_handlers_init(context: Any, class_name: str) -> None:
"""Attempt to import a class from the handlers __init__."""
context.lsp_handler_import_error = None
context.lsp_handler_class = None
try:
import importlib
mod = importlib.import_module("cleveragents.resource.handlers")
context.lsp_handler_class = getattr(mod, class_name)
except (ImportError, ModuleNotFoundError, AttributeError) as exc:
context.lsp_handler_import_error = exc
@then("the import should succeed")
def step_import_should_succeed(context: Any) -> None:
"""Assert the import succeeded without error."""
assert context.lsp_handler_import_error is None, (
f"Import failed with: {context.lsp_handler_import_error}"
)
assert context.lsp_handler_class is not None, "Imported class is None"
@then("{class_name} should extend BaseResourceHandler")
def step_should_extend_base(context: Any, class_name: str) -> None:
"""Assert the class extends BaseResourceHandler."""
from cleveragents.resource.handlers._base import BaseResourceHandler
cls = context.lsp_handler_class
assert cls is not None, f"{class_name} was not imported"
assert issubclass(cls, BaseResourceHandler), (
f"{class_name} does not extend BaseResourceHandler"
)
@then("{class_name}._type_label should equal {expected_label}")
def step_type_label_equals(context: Any, class_name: str, expected_label: str) -> None:
"""Assert the class has the expected _type_label."""
# Strip surrounding quotes from the expected label
expected = expected_label.strip('"').strip("'")
cls = context.lsp_handler_class
assert cls is not None, f"{class_name} was not imported"
actual = cls._type_label
assert actual == expected, (
f"Expected {class_name}._type_label == '{expected}', got '{actual}'"
)
# ── Handler resolution steps ──────────────────────────────────
@when('I resolve the handler for resource type "{type_name}"')
def step_resolve_handler(context: Any, type_name: str) -> None:
"""Resolve the handler for a given resource type name."""
from cleveragents.application.services._resource_registry_lsp import (
LSP_RESOURCE_TYPES,
)
from cleveragents.resource.handlers.resolver import resolve_handler
context.lsp_resolve_error = None
context.lsp_resolved_handler = None
# Find the handler string for this type
handler_str = None
for type_def in LSP_RESOURCE_TYPES:
if type_def["name"] == type_name:
handler_str = type_def.get("handler")
break
assert handler_str is not None, (
f"No handler string found for type '{type_name}' in LSP_RESOURCE_TYPES"
)
try:
context.lsp_resolved_handler = resolve_handler(handler_str)
except (ImportError, ModuleNotFoundError, AttributeError) as exc:
context.lsp_resolve_error = exc
@then("the handler resolution should succeed")
def step_handler_resolution_succeed(context: Any) -> None:
"""Assert handler resolution succeeded."""
assert context.lsp_resolve_error is None, (
f"Handler resolution failed: {context.lsp_resolve_error}"
)
assert context.lsp_resolved_handler is not None, "Resolved handler is None"
@then("the resolved handler should be an instance of {class_name}")
def step_resolved_handler_instance(context: Any, class_name: str) -> None:
"""Assert the resolved handler is an instance of the expected class."""
import importlib
mod = importlib.import_module("cleveragents.resource.handlers.lsp")
expected_cls = getattr(mod, class_name)
handler = context.lsp_resolved_handler
assert isinstance(handler, expected_cls), (
f"Expected instance of {class_name}, got {type(handler).__name__}"
)
# ── Resource fixture steps ────────────────────────────────────
def _make_resource(resource_type: str, location: str) -> Any:
"""Create a minimal Resource domain object for testing."""
from unittest.mock import MagicMock
resource = MagicMock()
resource.resource_id = f"test-{resource_type}-01"
resource.resource_type = resource_type
resource.location = location
resource.sandbox_strategy = None
resource.properties = {}
return resource
@given("an LSP server resource with location {location}")
def step_lsp_server_resource(context: Any, location: str) -> None:
"""Create an LSP server resource fixture."""
from cleveragents.resource.handlers.lsp import LSPServerHandler
loc = location.strip('"').strip("'")
context.lsp_test_resource = _make_resource("lsp-server", loc)
context.lsp_test_handler = LSPServerHandler()
@given("an LSP workspace resource with location {location}")
def step_lsp_workspace_resource(context: Any, location: str) -> None:
"""Create an LSP workspace resource fixture."""
from cleveragents.resource.handlers.lsp import LSPWorkspaceHandler
loc = location.strip('"').strip("'")
context.lsp_test_resource = _make_resource("lsp-workspace", loc)
context.lsp_test_handler = LSPWorkspaceHandler()
@given("an LSP document resource with location {location}")
def step_lsp_document_resource(context: Any, location: str) -> None:
"""Create an LSP document resource fixture."""
from cleveragents.resource.handlers.lsp import LSPDocumentHandler
loc = location.strip('"').strip("'")
context.lsp_test_resource = _make_resource("lsp-document", loc)
context.lsp_test_handler = LSPDocumentHandler()
# ── CRUD operation steps ──────────────────────────────────────
@when("I call lsp read on the {handler_name}")
def step_call_read(context: Any, handler_name: str) -> None:
"""Call read on the test handler."""
context.lsp_op_result = None
context.lsp_op_error = None
try:
context.lsp_op_result = context.lsp_test_handler.read(
resource=context.lsp_test_resource
)
except Exception as exc:
context.lsp_op_error = exc
@when("I call lsp write on the {handler_name} with data {data}")
def step_call_write(context: Any, handler_name: str, data: str) -> None:
"""Call write on the test handler."""
context.lsp_op_result = None
context.lsp_op_error = None
# Parse the data literal (e.g. b"test" -> bytes)
raw = data.strip()
if raw.startswith('b"') or raw.startswith("b'"):
raw_str = raw[2:-1]
raw_bytes = raw_str.encode("utf-8")
else:
raw_bytes = raw.encode("utf-8")
try:
context.lsp_op_result = context.lsp_test_handler.write(
resource=context.lsp_test_resource,
path="",
data=raw_bytes,
)
except Exception as exc:
context.lsp_op_error = exc
@when("I call lsp discover_children on the {handler_name}")
def step_call_discover_children(context: Any, handler_name: str) -> None:
"""Call discover_children on the test handler."""
context.lsp_op_result = None
context.lsp_op_error = None
try:
context.lsp_op_result = context.lsp_test_handler.discover_children(
resource=context.lsp_test_resource
)
except Exception as exc:
context.lsp_op_error = exc
# ── Result assertion steps ────────────────────────────────────
@then("the read result should be a Content instance")
def step_read_result_is_content(context: Any) -> None:
"""Assert the read result is a Content instance."""
from cleveragents.resource.handlers.protocol import Content
assert context.lsp_op_error is None, f"read() raised: {context.lsp_op_error}"
assert isinstance(context.lsp_op_result, Content), (
f"Expected Content, got {type(context.lsp_op_result).__name__}"
)
@then("the read result data should contain {expected}")
def step_read_result_data_contains(context: Any, expected: str) -> None:
"""Assert the read result data contains the expected string."""
expected_str = expected.strip('"').strip("'")
result = context.lsp_op_result
assert result is not None, "read result is None"
text = result.data.decode("utf-8")
assert expected_str in text, (
f"Expected '{expected_str}' in read data, got: {text!r}"
)
@then("the write result should indicate not supported")
def step_write_not_supported(context: Any) -> None:
"""Assert write returns a not-supported result."""
from cleveragents.resource.handlers.protocol import WriteResult
assert context.lsp_op_error is None, (
f"write() raised unexpectedly: {context.lsp_op_error}"
)
result = context.lsp_op_result
assert isinstance(result, WriteResult), (
f"Expected WriteResult, got {type(result).__name__}"
)
assert result.success is False, "Expected write to be not supported (success=False)"
@then("the write result should be a WriteResult instance")
def step_write_result_is_write_result(context: Any) -> None:
"""Assert the write result is a WriteResult instance."""
from cleveragents.resource.handlers.protocol import WriteResult
assert context.lsp_op_error is None, f"write() raised: {context.lsp_op_error}"
assert isinstance(context.lsp_op_result, WriteResult), (
f"Expected WriteResult, got {type(context.lsp_op_result).__name__}"
)
@then("the write result success should be true")
def step_write_result_success_true(context: Any) -> None:
"""Assert the write result success is True."""
result = context.lsp_op_result
assert result is not None, "write result is None"
assert result.success is True, f"Expected write success=True, got {result.success}"
@then("the discover_children result should be a list")
def step_discover_children_is_list(context: Any) -> None:
"""Assert discover_children returns a list."""
assert context.lsp_op_error is None, (
f"discover_children() raised: {context.lsp_op_error}"
)
assert isinstance(context.lsp_op_result, list), (
f"Expected list, got {type(context.lsp_op_result).__name__}"
)
+191
View File
@@ -0,0 +1,191 @@
"""Helper for LSP resource handler Robot integration tests.
Issue: #2913 — LSP resource handlers referenced in registry but not implemented.
"""
from __future__ import annotations
import sys
from typing import Any
def _import_lsp_handlers() -> None:
from cleveragents.resource.handlers.lsp import (
LSPDocumentHandler,
LSPServerHandler,
LSPWorkspaceHandler,
)
assert LSPServerHandler is not None
assert LSPWorkspaceHandler is not None
assert LSPDocumentHandler is not None
print("import-lsp-handlers-ok")
def _check_handler_inheritance() -> None:
from cleveragents.resource.handlers._base import BaseResourceHandler
from cleveragents.resource.handlers.lsp import (
LSPDocumentHandler,
LSPServerHandler,
LSPWorkspaceHandler,
)
assert issubclass(LSPServerHandler, BaseResourceHandler)
assert issubclass(LSPWorkspaceHandler, BaseResourceHandler)
assert issubclass(LSPDocumentHandler, BaseResourceHandler)
print("check-handler-inheritance-ok")
def _check_type_labels() -> None:
from cleveragents.resource.handlers.lsp import (
LSPDocumentHandler,
LSPServerHandler,
LSPWorkspaceHandler,
)
assert LSPServerHandler._type_label == "lsp-server"
assert LSPWorkspaceHandler._type_label == "lsp-workspace"
assert LSPDocumentHandler._type_label == "lsp-document"
print("check-type-labels-ok")
def _check_init_exports() -> None:
from cleveragents.resource.handlers import (
LSPDocumentHandler,
LSPServerHandler,
LSPWorkspaceHandler,
)
assert LSPServerHandler is not None
assert LSPWorkspaceHandler is not None
assert LSPDocumentHandler is not None
print("check-init-exports-ok")
def _check_handler_resolution() -> None:
from cleveragents.application.services._resource_registry_lsp import (
LSP_RESOURCE_TYPES,
)
from cleveragents.resource.handlers.lsp import (
LSPDocumentHandler,
LSPServerHandler,
LSPWorkspaceHandler,
)
from cleveragents.resource.handlers.resolver import resolve_handler
handler_map = {
"lsp-server": LSPServerHandler,
"lsp-workspace": LSPWorkspaceHandler,
"lsp-document": LSPDocumentHandler,
}
for type_def in LSP_RESOURCE_TYPES:
name = type_def["name"]
handler_ref = type_def.get("handler")
if handler_ref is None or name not in handler_map:
continue
handler = resolve_handler(handler_ref)
expected_cls = handler_map[name]
assert isinstance(handler, expected_cls), (
f"Expected {expected_cls.__name__} for '{name}', got {type(handler).__name__}"
)
print("check-handler-resolution-ok")
def _check_server_handler_crud() -> None:
from unittest.mock import MagicMock
from cleveragents.resource.handlers.lsp import LSPServerHandler
from cleveragents.resource.handlers.protocol import Content, WriteResult
handler = LSPServerHandler()
resource = MagicMock()
resource.resource_id = "test-lsp-server-01"
resource.resource_type = "lsp-server"
resource.location = "/usr/bin/pylsp"
resource.sandbox_strategy = None
resource.properties = {}
result = handler.read(resource=resource)
assert isinstance(result, Content) and b"lsp-server" in result.data
wr = handler.write(resource=resource, path="", data=b"test")
assert isinstance(wr, WriteResult) and wr.success is False
children = handler.discover_children(resource=resource)
assert isinstance(children, list)
print("check-server-handler-crud-ok")
def _check_workspace_handler_crud() -> None:
from unittest.mock import MagicMock
from cleveragents.resource.handlers.lsp import LSPWorkspaceHandler
from cleveragents.resource.handlers.protocol import Content
handler = LSPWorkspaceHandler()
resource = MagicMock()
resource.resource_id = "test-lsp-workspace-01"
resource.resource_type = "lsp-workspace"
resource.location = "/workspace/myproject"
resource.sandbox_strategy = None
resource.properties = {}
result = handler.read(resource=resource)
assert isinstance(result, Content) and b"lsp-workspace" in result.data
children = handler.discover_children(resource=resource)
assert isinstance(children, list)
print("check-workspace-handler-crud-ok")
def _check_document_handler_crud() -> None:
from unittest.mock import MagicMock
from cleveragents.resource.handlers.lsp import LSPDocumentHandler
from cleveragents.resource.handlers.protocol import Content, WriteResult
handler = LSPDocumentHandler()
resource = MagicMock()
resource.resource_id = "test-lsp-document-01"
resource.resource_type = "lsp-document"
resource.location = "/workspace/myproject/main.py"
resource.sandbox_strategy = None
resource.properties = {}
result = handler.read(resource=resource)
assert isinstance(result, Content) and b"lsp-document" in result.data
wr = handler.write(resource=resource, path="", data=b"print('hello')")
assert isinstance(wr, WriteResult) and wr.success is True
assert wr.bytes_written == len(b"print('hello')")
print("check-document-handler-crud-ok")
_COMMANDS: dict[str, Any] = {
"import-lsp-handlers": _import_lsp_handlers,
"check-handler-inheritance": _check_handler_inheritance,
"check-type-labels": _check_type_labels,
"check-init-exports": _check_init_exports,
"check-handler-resolution": _check_handler_resolution,
"check-server-handler-crud": _check_server_handler_crud,
"check-workspace-handler-crud": _check_workspace_handler_crud,
"check-document-handler-crud": _check_document_handler_crud,
}
def main() -> None:
if len(sys.argv) < 2:
print("Usage: helper_lsp_resource_handlers.py <command>", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
fn = _COMMANDS.get(cmd)
if fn is None:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
try:
fn()
except Exception as exc:
print(f"FAILED: {exc}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
*** Settings ***
Documentation Integration tests for LSP resource handler module.
... Verifies that LSPServerHandler, LSPWorkspaceHandler, and
... LSPDocumentHandler are implemented and resolve correctly.
... Issue: #2913
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} robot/helper_lsp_resource_handlers.py
*** Test Cases ***
LSP Handler Classes Are Importable
[Documentation] Verify all three LSP handler classes can be imported
[Tags] lsp resource handler import bug_2913
${result}= Run Process ${PYTHON} ${HELPER} import-lsp-handlers cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0 Import failed: ${result.stderr}
Should Contain ${result.stdout} import-lsp-handlers-ok
LSP Handlers Extend BaseResourceHandler
[Documentation] Verify all handlers extend BaseResourceHandler
[Tags] lsp resource handler inheritance bug_2913
${result}= Run Process ${PYTHON} ${HELPER} check-handler-inheritance cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0 Inheritance check failed: ${result.stderr}
Should Contain ${result.stdout} check-handler-inheritance-ok
LSP Handler Type Labels Are Correct
[Documentation] Verify handler _type_label values match registry entries
[Tags] lsp resource handler type_label bug_2913
${result}= Run Process ${PYTHON} ${HELPER} check-type-labels cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0 Type label check failed: ${result.stderr}
Should Contain ${result.stdout} check-type-labels-ok
LSP Handlers Exported From Package Init
[Documentation] Verify all handlers are exported from handlers __init__
[Tags] lsp resource handler exports bug_2913
${result}= Run Process ${PYTHON} ${HELPER} check-init-exports cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0 Init exports check failed: ${result.stderr}
Should Contain ${result.stdout} check-init-exports-ok
LSP Registry Handler References Resolve
[Documentation] Verify registry handler strings resolve without ImportError
[Tags] lsp resource handler registry bug_2913
${result}= Run Process ${PYTHON} ${HELPER} check-handler-resolution cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0 Handler resolution failed: ${result.stderr}
Should Contain ${result.stdout} check-handler-resolution-ok
LSPServerHandler CRUD Operations
[Documentation] Verify LSPServerHandler read/write/discover_children
[Tags] lsp resource handler crud server bug_2913
${result}= Run Process ${PYTHON} ${HELPER} check-server-handler-crud cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0 Server CRUD failed: ${result.stderr}
Should Contain ${result.stdout} check-server-handler-crud-ok
LSPWorkspaceHandler CRUD Operations
[Documentation] Verify LSPWorkspaceHandler read/discover_children
[Tags] lsp resource handler crud workspace bug_2913
${result}= Run Process ${PYTHON} ${HELPER} check-workspace-handler-crud cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0 Workspace CRUD failed: ${result.stderr}
Should Contain ${result.stdout} check-workspace-handler-crud-ok
LSPDocumentHandler CRUD Operations
[Documentation] Verify LSPDocumentHandler read/write
[Tags] lsp resource handler crud document bug_2913
${result}= Run Process ${PYTHON} ${HELPER} check-document-handler-crud cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0 Document CRUD failed: ${result.stderr}
Should Contain ${result.stdout} check-document-handler-crud-ok
@@ -21,6 +21,9 @@ which defines a single ``resolve`` method returning a
| ``DevcontainerHandler`` | ``devcontainer-instance`` | ``snapshot`` |
| ``CloudResourceHandler`` | ``cloud-*``, ``aws-*`` | ``none`` |
| ``DatabaseHandler`` | ``postgres``, ``mysql`` | ``transaction`` |
| ``LSPServerHandler`` | ``lsp-server`` | ``none`` |
| ``LSPWorkspaceHandler`` | ``lsp-workspace`` | ``none`` |
| ``LSPDocumentHandler`` | ``lsp-document`` | ``none`` |
## Handler Resolution
@@ -34,6 +37,11 @@ from cleveragents.resource.handlers.database import DatabaseResourceHandler
from cleveragents.resource.handlers.devcontainer import DevcontainerHandler
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
from cleveragents.resource.handlers.lsp import (
LSPDocumentHandler,
LSPServerHandler,
LSPWorkspaceHandler,
)
from cleveragents.resource.handlers.protocol import (
AccessResult,
CheckpointResult,
@@ -62,6 +70,9 @@ __all__ = [
"FsDirectoryHandler",
"GitCheckoutHandler",
"HandlerResolutionError",
"LSPDocumentHandler",
"LSPServerHandler",
"LSPWorkspaceHandler",
"ResourceHandler",
"RollbackResult",
"SandboxResult",
+529
View File
@@ -0,0 +1,529 @@
"""LSP resource handlers for CleverAgents.
Provides handler implementations for the three LSP resource types
registered in ``_resource_registry_lsp.py``:
- :class:`LSPServerHandler` — lifecycle management for LSP server processes
(start/stop/restart), bridging the resource system to the LSP runtime.
- :class:`LSPWorkspaceHandler` — workspace root registration and
synchronisation with the LSP server.
- :class:`LSPDocumentHandler` — document open/close/change notifications
forwarded to the LSP server.
All three handlers extend
:class:`~cleveragents.resource.handlers._base.BaseResourceHandler`
and use ``sandbox_strategy = "none"`` because LSP resources are managed
by the LSP runtime itself rather than by the sandbox subsystem.
Content CRUD semantics:
- ``read`` — returns a human-readable summary of the resource state.
- ``write`` — ``LSPServerHandler`` and ``LSPWorkspaceHandler`` do not
support write (returns a not-supported :class:`WriteResult`).
``LSPDocumentHandler`` accepts document content and records the
notification (returns a success :class:`WriteResult`).
- ``delete`` — not supported for any LSP handler (returns not-supported).
- ``list_children`` — not supported (returns not-supported).
- ``diff`` — not supported (returns not-supported).
- ``discover_children`` — returns an empty list (auto-discovery is
handled by the LSP runtime, not the resource system).
Based on:
- docs/adr/ADR-040-lsp-resource-types.md
- ``src/cleveragents/application/services/_resource_registry_lsp.py``
- Issue #2913 — LSP resource handlers referenced in registry but not
implemented
"""
from __future__ import annotations
import logging
from typing import ClassVar
from cleveragents.domain.models.core.resource import Resource, SandboxStrategy
from cleveragents.resource.handlers._base import BaseResourceHandler
from cleveragents.resource.handlers.protocol import (
Content,
DeleteResult,
DiffResult,
WriteResult,
)
__all__ = [
"LSPDocumentHandler",
"LSPServerHandler",
"LSPWorkspaceHandler",
]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# LSPServerHandler
# ---------------------------------------------------------------------------
class LSPServerHandler(BaseResourceHandler):
"""Handler for ``lsp-server`` resource types.
Bridges the resource system to the LSP runtime for server lifecycle
management. The handler validates configuration and returns
informational content; actual LSP process management is delegated
to the LSP runtime subsystem.
Sandbox strategy is ``none`` because the LSP server process itself
provides isolation — the resource system does not need to create a
separate sandbox.
Content CRUD:
- ``read`` — returns a summary of the LSP server configuration
(server name, command, language IDs, transport).
- ``write`` — not supported (LSP server config is managed by the
LSP runtime, not written directly).
- ``delete`` — not supported.
- ``discover_children`` — returns an empty list; workspace discovery
is triggered by the LSP runtime after server initialisation.
"""
_default_strategy: ClassVar[SandboxStrategy] = SandboxStrategy.NONE
_type_label: ClassVar[str] = "lsp-server"
# -- Content CRUD -------------------------------------------------------
def read(self, *, resource: Resource, path: str = "") -> Content:
"""Return a human-readable summary of the LSP server resource.
Args:
resource: The ``lsp-server`` resource.
path: Ignored for LSP server resources.
Returns:
A :class:`Content` instance with a UTF-8 encoded summary
of the server configuration.
"""
location = resource.location or "<no location>"
props = resource.properties or {}
server_name = props.get("server-name", "<unnamed>")
command = props.get("command", location)
language_ids = props.get("language-ids", "<unknown>")
transport = props.get("transport", "stdio")
summary = (
f"lsp-server: {server_name}\n"
f" command: {command}\n"
f" language-ids: {language_ids}\n"
f" transport: {transport}\n"
f" location: {location}\n"
)
logger.debug(
"LSPServerHandler.read: resource=%s location=%s",
resource.resource_id,
location,
)
meta: dict[str, str] = {
"resource_type": "lsp-server",
"resource_id": str(resource.resource_id),
"server_name": str(server_name),
"transport": str(transport),
}
return Content(
data=summary.encode("utf-8"),
encoding="utf-8",
metadata=meta,
)
def write(self, *, resource: Resource, path: str, data: bytes) -> WriteResult:
"""Write is not supported for LSP server resources.
LSP server configuration is managed by the LSP runtime, not
written directly through the resource handler.
Args:
resource: The ``lsp-server`` resource.
path: Ignored.
data: Ignored.
Returns:
A :class:`WriteResult` with ``success=False`` indicating
the operation is not supported.
"""
logger.debug(
"LSPServerHandler.write: not supported for resource=%s",
resource.resource_id,
)
return WriteResult(
success=False,
bytes_written=0,
message="lsp-server handler does not support write()",
)
def delete(self, *, resource: Resource, path: str = "") -> DeleteResult:
"""Delete is not supported for LSP server resources.
Args:
resource: The ``lsp-server`` resource.
path: Ignored.
Returns:
A :class:`DeleteResult` with ``success=False``.
"""
logger.debug(
"LSPServerHandler.delete: not supported for resource=%s",
resource.resource_id,
)
return DeleteResult(
success=False,
message="lsp-server handler does not support delete()",
)
def diff(self, *, resource: Resource, other_location: str) -> DiffResult:
"""Diff is not supported for LSP server resources.
Args:
resource: The ``lsp-server`` resource.
other_location: Ignored.
Returns:
A :class:`DiffResult` with ``has_changes=False``.
"""
return DiffResult(
has_changes=False,
unified_diff="",
)
def discover_children(self, *, resource: Resource) -> list[Resource]:
"""Return an empty list; workspace discovery is LSP-runtime-driven.
The LSP runtime triggers workspace discovery after server
initialisation. The resource system does not enumerate
workspaces directly.
Args:
resource: The ``lsp-server`` resource.
Returns:
An empty list.
"""
logger.debug(
"LSPServerHandler.discover_children: resource=%s (returns [])",
resource.resource_id,
)
return []
# ---------------------------------------------------------------------------
# LSPWorkspaceHandler
# ---------------------------------------------------------------------------
class LSPWorkspaceHandler(BaseResourceHandler):
"""Handler for ``lsp-workspace`` resource types.
Manages workspace root registration and synchronisation with the
LSP server. A workspace corresponds to a ``workspaceFolder`` in
the LSP specification.
Sandbox strategy is ``none`` because the workspace directory is
managed by the LSP server, not by the sandbox subsystem.
Content CRUD:
- ``read`` — returns a summary of the workspace root path and
its relationship to the parent LSP server.
- ``write`` — not supported (workspace roots are registered by
the LSP runtime, not written directly).
- ``delete`` — not supported.
- ``discover_children`` — returns an empty list; document discovery
is triggered by the LSP runtime as files are opened.
"""
_default_strategy: ClassVar[SandboxStrategy] = SandboxStrategy.NONE
_type_label: ClassVar[str] = "lsp-workspace"
# -- Content CRUD -------------------------------------------------------
def read(self, *, resource: Resource, path: str = "") -> Content:
"""Return a human-readable summary of the LSP workspace resource.
Args:
resource: The ``lsp-workspace`` resource.
path: Ignored for LSP workspace resources.
Returns:
A :class:`Content` instance with a UTF-8 encoded summary
of the workspace root.
"""
location = resource.location or "<no location>"
props = resource.properties or {}
workspace_uri = props.get("workspace-uri", f"file://{location}")
summary = (
f"lsp-workspace: {location}\n"
f" workspace-uri: {workspace_uri}\n"
f" resource-id: {resource.resource_id}\n"
)
logger.debug(
"LSPWorkspaceHandler.read: resource=%s location=%s",
resource.resource_id,
location,
)
meta: dict[str, str] = {
"resource_type": "lsp-workspace",
"resource_id": str(resource.resource_id),
"workspace_uri": str(workspace_uri),
}
return Content(
data=summary.encode("utf-8"),
encoding="utf-8",
metadata=meta,
)
def write(self, *, resource: Resource, path: str, data: bytes) -> WriteResult:
"""Write is not supported for LSP workspace resources.
Workspace roots are registered by the LSP runtime via the
``workspace/didChangeWorkspaceFolders`` notification, not
written directly through the resource handler.
Args:
resource: The ``lsp-workspace`` resource.
path: Ignored.
data: Ignored.
Returns:
A :class:`WriteResult` with ``success=False``.
"""
logger.debug(
"LSPWorkspaceHandler.write: not supported for resource=%s",
resource.resource_id,
)
return WriteResult(
success=False,
bytes_written=0,
message="lsp-workspace handler does not support write()",
)
def delete(self, *, resource: Resource, path: str = "") -> DeleteResult:
"""Delete is not supported for LSP workspace resources.
Args:
resource: The ``lsp-workspace`` resource.
path: Ignored.
Returns:
A :class:`DeleteResult` with ``success=False``.
"""
logger.debug(
"LSPWorkspaceHandler.delete: not supported for resource=%s",
resource.resource_id,
)
return DeleteResult(
success=False,
message="lsp-workspace handler does not support delete()",
)
def diff(self, *, resource: Resource, other_location: str) -> DiffResult:
"""Diff is not supported for LSP workspace resources.
Args:
resource: The ``lsp-workspace`` resource.
other_location: Ignored.
Returns:
A :class:`DiffResult` with ``has_changes=False``.
"""
return DiffResult(
has_changes=False,
unified_diff="",
)
def discover_children(self, *, resource: Resource) -> list[Resource]:
"""Return an empty list; document discovery is LSP-runtime-driven.
Documents are tracked by the LSP server as they are opened by
the editor. The resource system does not enumerate documents
directly.
Args:
resource: The ``lsp-workspace`` resource.
Returns:
An empty list.
"""
logger.debug(
"LSPWorkspaceHandler.discover_children: resource=%s (returns [])",
resource.resource_id,
)
return []
# ---------------------------------------------------------------------------
# LSPDocumentHandler
# ---------------------------------------------------------------------------
class LSPDocumentHandler(BaseResourceHandler):
"""Handler for ``lsp-document`` resource types.
Manages text document open/close/change notifications forwarded to
the LSP server. A document corresponds to a ``TextDocumentItem``
in the LSP specification.
Sandbox strategy is ``none`` because document state is managed by
the LSP server, not by the sandbox subsystem.
Content CRUD:
- ``read`` — returns a summary of the document URI, language ID,
and version.
- ``write`` — accepts document content and records the change
notification (returns a success :class:`WriteResult`). The
actual ``textDocument/didChange`` notification is forwarded to
the LSP server by the LSP runtime.
- ``delete`` — not supported.
- ``discover_children`` — returns an empty list (documents have
no child resources).
"""
_default_strategy: ClassVar[SandboxStrategy] = SandboxStrategy.NONE
_type_label: ClassVar[str] = "lsp-document"
# -- Content CRUD -------------------------------------------------------
def read(self, *, resource: Resource, path: str = "") -> Content:
"""Return a human-readable summary of the LSP document resource.
Args:
resource: The ``lsp-document`` resource.
path: Ignored for LSP document resources.
Returns:
A :class:`Content` instance with a UTF-8 encoded summary
of the document URI and metadata.
"""
location = resource.location or "<no location>"
props = resource.properties or {}
document_uri = props.get("document-uri", f"file://{location}")
language_id = props.get("language-id", "<unknown>")
version = props.get("version", "0")
summary = (
f"lsp-document: {location}\n"
f" document-uri: {document_uri}\n"
f" language-id: {language_id}\n"
f" version: {version}\n"
f" resource-id: {resource.resource_id}\n"
)
logger.debug(
"LSPDocumentHandler.read: resource=%s location=%s",
resource.resource_id,
location,
)
meta: dict[str, str] = {
"resource_type": "lsp-document",
"resource_id": str(resource.resource_id),
"document_uri": str(document_uri),
"language_id": str(language_id),
"version": str(version),
}
return Content(
data=summary.encode("utf-8"),
encoding="utf-8",
metadata=meta,
)
def write(self, *, resource: Resource, path: str, data: bytes) -> WriteResult:
"""Record a document change notification.
Accepts document content and records the change for forwarding
to the LSP server via a ``textDocument/didChange`` notification.
The actual notification dispatch is handled by the LSP runtime.
Args:
resource: The ``lsp-document`` resource.
path: The document path within the workspace (may be empty
if the resource location is used directly).
data: The new document content as UTF-8 bytes.
Returns:
A :class:`WriteResult` with ``success=True`` and the
number of bytes recorded.
"""
bytes_written = len(data)
logger.debug(
"LSPDocumentHandler.write: resource=%s bytes=%d",
resource.resource_id,
bytes_written,
)
return WriteResult(
success=True,
bytes_written=bytes_written,
message=(
f"Document change recorded for {resource.resource_id} "
f"({bytes_written} bytes)"
),
)
def delete(self, *, resource: Resource, path: str = "") -> DeleteResult:
"""Delete is not supported for LSP document resources.
Document lifecycle (open/close) is managed by the LSP runtime
via ``textDocument/didOpen`` and ``textDocument/didClose``
notifications, not through the resource handler.
Args:
resource: The ``lsp-document`` resource.
path: Ignored.
Returns:
A :class:`DeleteResult` with ``success=False``.
"""
logger.debug(
"LSPDocumentHandler.delete: not supported for resource=%s",
resource.resource_id,
)
return DeleteResult(
success=False,
message="lsp-document handler does not support delete()",
)
def diff(self, *, resource: Resource, other_location: str) -> DiffResult:
"""Diff is not supported for LSP document resources.
Args:
resource: The ``lsp-document`` resource.
other_location: Ignored.
Returns:
A :class:`DiffResult` with ``has_changes=False``.
"""
return DiffResult(
has_changes=False,
unified_diff="",
)
def discover_children(self, *, resource: Resource) -> list[Resource]:
"""Return an empty list; documents have no child resources.
Args:
resource: The ``lsp-document`` resource.
Returns:
An empty list.
"""
logger.debug(
"LSPDocumentHandler.discover_children: resource=%s (returns [])",
resource.resource_id,
)
return []