Files
cleveragents-core/features/steps/lsp_registry_steps.py
T
hamza.khyari 4ff075e0da
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 18s
CI / quality (pull_request) Successful in 32s
CI / typecheck (pull_request) Successful in 56s
CI / security (pull_request) Successful in 57s
CI / lint (pull_request) Successful in 3m19s
CI / integration_tests (pull_request) Successful in 3m44s
CI / unit_tests (pull_request) Successful in 6m39s
CI / docker (pull_request) Successful in 1m9s
CI / e2e_tests (pull_request) Successful in 9m38s
CI / coverage (pull_request) Successful in 11m19s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-regression (pull_request) Failing after 1h13m38s
feat(lsp): implement functional LSP runtime
Replace local-mode stubs with real LSP protocol support:

- StdioTransport: subprocess management with JSON-RPC framing
- LspClient: LSP protocol (initialize/shutdown/diagnostics/completions)
- LspLifecycleManager: reference-counted instances, health checks, crash restart
- LspRuntime: registry-based server lookup, auto-restart on crash
- LspToolAdapter: runtime-delegating handlers with local-mode fallback
- LanguageDiscovery: 4-layer detection (extension, shebang, UKO, project)
- activate_bindings/deactivate_bindings: actor compiler LSP binding wiring

Tests: 27 Behave scenarios, 6 Robot integration tests, 250 existing pass.

ISSUES CLOSED: #826
2026-03-24 13:01:12 +00:00

481 lines
16 KiB
Python

"""Step definitions for LSP Registry, Runtime, and Tool Adapter tests.
Covers LspRegistry, LspRuntime, LspToolAdapter, LspBinding,
LspServerConfig, and the LSP error hierarchy.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError as PydanticValidationError
from cleveragents.core.exceptions import CleverAgentsError
from cleveragents.lsp.errors import (
LspError,
LspNotAvailableError,
LspServerNotFoundError,
)
from cleveragents.lsp.models import (
LspBinding,
LspCapability,
LspServerConfig,
)
from cleveragents.lsp.registry import LspRegistry
from cleveragents.lsp.runtime import LspRuntime
from cleveragents.lsp.tool_adapter import LspToolAdapter
# ---------------------------------------------------------------------------
# Capability name -> enum mapping
# ---------------------------------------------------------------------------
_CAP_MAP: dict[str, LspCapability] = {c.value: c for c in LspCapability}
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a clean LSP registry")
def step_clean_lsp_registry(context: Context) -> None:
context.lsp_registry = LspRegistry()
context.lsp_config: LspServerConfig | None = None
context.lsp_result: Any = None
context.lsp_error: Exception | None = None
context.lsp_tool_specs: list[dict[str, Any]] = []
# ---------------------------------------------------------------------------
# Config creation
# ---------------------------------------------------------------------------
@given(
'an LSP server config named "{name}" for language "{language}" '
'with command "{command}"'
)
def step_lsp_config_single_lang(
context: Context, name: str, language: str, command: str
) -> None:
context.lsp_config = LspServerConfig(
name=name,
languages=[language.strip().lower()],
command=command,
)
@given(
'an LSP server config named "{name}" for languages "{languages}" '
'with command "{command}"'
)
def step_lsp_config_multi_lang(
context: Context, name: str, languages: str, command: str
) -> None:
lang_list = [lang.strip().lower() for lang in languages.split(",")]
context.lsp_config = LspServerConfig(
name=name,
languages=lang_list,
command=command,
)
@given('the LSP server config has capabilities "{caps_str}"')
def step_lsp_config_capabilities(context: Context, caps_str: str) -> None:
caps = [_CAP_MAP[c.strip()] for c in caps_str.split(",")]
assert context.lsp_config is not None
context.lsp_config = context.lsp_config.model_copy(update={"capabilities": caps})
# ---------------------------------------------------------------------------
# Register
# ---------------------------------------------------------------------------
@when("the LSP server is registered")
@given("the LSP server is registered")
def step_register_lsp_server(context: Context) -> None:
assert context.lsp_config is not None
context.lsp_registry.register(context.lsp_config)
@when('a second LSP server with the same name "{name}" is registered')
def step_register_duplicate(context: Context, name: str) -> None:
dup = LspServerConfig(name=name, command="dup-cmd", languages=["python"])
try:
context.lsp_registry.register(dup)
context.lsp_error = None
except ValueError as exc:
context.lsp_error = exc
@given("the following LSP servers are registered:")
def step_register_table(context: Context) -> None:
for row in context.table:
config = LspServerConfig(
name=row["name"],
languages=[row["language"].strip().lower()],
command=row["command"],
)
context.lsp_registry.register(config)
# ---------------------------------------------------------------------------
# Lookup
# ---------------------------------------------------------------------------
@when('I look up LSP server "{name}"')
def step_lookup(context: Context, name: str) -> None:
context.lsp_result = context.lsp_registry.get(name)
@when('I look up LSP server "{name}" with get_or_raise')
def step_lookup_or_raise(context: Context, name: str) -> None:
try:
context.lsp_result = context.lsp_registry.get_or_raise(name)
context.lsp_error = None
except LspServerNotFoundError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
@when("I list all LSP servers")
def step_list_all(context: Context) -> None:
context.lsp_result = context.lsp_registry.list_servers()
@when('I list LSP servers with namespace "{namespace}"')
def step_list_ns(context: Context, namespace: str) -> None:
context.lsp_result = context.lsp_registry.list_servers(namespace=namespace)
@when('I list LSP servers with language "{language}"')
def step_list_lang(context: Context, language: str) -> None:
context.lsp_result = context.lsp_registry.list_servers(language=language)
# ---------------------------------------------------------------------------
# Remove
# ---------------------------------------------------------------------------
@when('I remove LSP server "{name}"')
def step_remove(context: Context, name: str) -> None:
context.lsp_result = context.lsp_registry.remove(name)
# ---------------------------------------------------------------------------
# Runtime
# ---------------------------------------------------------------------------
@given("a local-mode LSP runtime")
def step_lsp_runtime(context: Context) -> None:
context.lsp_runtime = LspRuntime()
@when('I call start_server on the runtime with name "{name}" and workspace "{ws}"')
def step_runtime_start(context: Context, name: str, ws: str) -> None:
try:
context.lsp_runtime.start_server(name, ws)
context.lsp_error = None
except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc:
context.lsp_error = exc
@when('I call stop_server on the runtime with name "{name}"')
def step_runtime_stop(context: Context, name: str) -> None:
try:
context.lsp_runtime.stop_server(name)
context.lsp_error = None
except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc:
context.lsp_error = exc
@when('I call get_diagnostics on the runtime with name "{name}" and file "{file_path}"')
def step_runtime_diagnostics(context: Context, name: str, file_path: str) -> None:
try:
context.lsp_result = context.lsp_runtime.get_diagnostics(name, file_path)
context.lsp_error = None
except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc:
context.lsp_error = exc
@when(
'I call get_completions on the runtime with name "{name}" '
'file "{file_path}" line {line:d} column {col:d}'
)
def step_runtime_completions(
context: Context, name: str, file_path: str, line: int, col: int
) -> None:
try:
context.lsp_result = context.lsp_runtime.get_completions(
name, file_path, line, col
)
context.lsp_error = None
except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# Tool adapter
# ---------------------------------------------------------------------------
@when("I generate tool specs from the adapter")
def step_generate_specs(context: Context) -> None:
adapter = LspToolAdapter()
assert context.lsp_config is not None
context.lsp_tool_specs = adapter.generate_tool_specs(context.lsp_config)
@when("I invoke the first tool spec handler")
def step_invoke_handler(context: Context) -> None:
assert context.lsp_tool_specs, "No tool specs generated"
handler = context.lsp_tool_specs[0]["handler"]
try:
handler()
context.lsp_error = None
except LspNotAvailableError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# Binding
# ---------------------------------------------------------------------------
@when('I create a binding for node "{node}" to server "{server}"')
def step_create_binding(context: Context, node: str, server: str) -> None:
try:
context.lsp_binding = LspBinding(
node_name=node,
lsp_server_name=server,
)
context.lsp_error = None
except PydanticValidationError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# Model validation
# ---------------------------------------------------------------------------
@when('I try to create an LSP server config with name "{name}" and command "{command}"')
def step_invalid_config(context: Context, name: str, command: str) -> None:
try:
context.lsp_config = LspServerConfig(name=name, command=command)
context.lsp_error = None
except PydanticValidationError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# Error creation
# ---------------------------------------------------------------------------
@when("I create an LspNotAvailableError with default message")
def step_create_not_available_error(context: Context) -> None:
context.lsp_error = LspNotAvailableError()
@when('I create an LspServerNotFoundError for server "{name}"')
def step_create_not_found_error(context: Context, name: str) -> None:
context.lsp_error = LspServerNotFoundError(name)
@when("I try to create an LspServerNotFoundError with empty name")
def step_create_not_found_empty(context: Context) -> None:
try:
LspServerNotFoundError("")
context.lsp_error = None
except ValueError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# Assertions
# ---------------------------------------------------------------------------
@then('the LSP registry should contain "{name}"')
def step_registry_contains(context: Context, name: str) -> None:
assert name in context.lsp_registry, f"Registry should contain '{name}'"
@then("the LSP registry should have {count:d} server")
@then("the LSP registry should have {count:d} servers")
def step_registry_count(context: Context, count: int) -> None:
assert len(context.lsp_registry) == count, (
f"Expected {count} servers, got {len(context.lsp_registry)}"
)
@then('the LSP server "{name}" should support language "{lang}"')
def step_server_supports_lang(context: Context, name: str, lang: str) -> None:
config = context.lsp_registry.get(name)
assert config is not None, f"Server '{name}' not found"
assert lang.lower() in config.languages, f"Server '{name}' should support '{lang}'"
@then('the LSP server "{name}" should have {count:d} capabilities')
def step_server_cap_count(context: Context, name: str, count: int) -> None:
config = context.lsp_registry.get(name)
assert config is not None, f"Server '{name}' not found"
assert len(config.capabilities) == count, (
f"Expected {count} capabilities, got {len(config.capabilities)}"
)
@then("the LSP lookup result should not be None")
def step_result_not_none(context: Context) -> None:
assert context.lsp_result is not None
@then("the LSP lookup result should be None")
def step_result_none(context: Context) -> None:
assert context.lsp_result is None
@then('the LSP lookup result name should be "{name}"')
def step_result_name(context: Context, name: str) -> None:
assert context.lsp_result.name == name
@then("the LSP server list should contain {count:d} servers")
def step_list_count(context: Context, count: int) -> None:
assert len(context.lsp_result) == count, (
f"Expected {count}, got {len(context.lsp_result)}"
)
@then("the LSP removal result should be True")
def step_removal_true(context: Context) -> None:
assert context.lsp_result is True
@then("the LSP removal result should be False")
def step_removal_false(context: Context) -> None:
assert context.lsp_result is False
# NOTE: The @then step 'a ValueError should be raised mentioning ...'
# is defined in config_service_coverage_steps.py (shared step).
@then("an LspNotAvailableError should be raised")
def step_not_available_error(context: Context) -> None:
assert context.lsp_error is not None, "Expected an error"
assert isinstance(context.lsp_error, LspNotAvailableError), (
f"Expected LspNotAvailableError, got {type(context.lsp_error).__name__}"
)
@then("an LSP error should be raised")
def step_any_lsp_error(context: Context) -> None:
assert context.lsp_error is not None, "Expected an LSP error"
assert isinstance(context.lsp_error, LspError), (
f"Expected LspError subclass, got {type(context.lsp_error).__name__}"
)
@then('the LSP error message should mention "{text}"')
@then('the LSP error message should contain "{text}"')
def step_lsp_error_mentions(context: Context, text: str) -> None:
assert context.lsp_error is not None
assert text in str(context.lsp_error), (
f"Error '{context.lsp_error}' should mention '{text}'"
)
@then('an LspServerNotFoundError should be raised for "{name}"')
def step_not_found_error(context: Context, name: str) -> None:
assert context.lsp_error is not None, "Expected an error"
assert isinstance(context.lsp_error, LspServerNotFoundError), (
f"Expected LspServerNotFoundError, got {type(context.lsp_error).__name__}"
)
assert name in str(context.lsp_error)
@then("the tool spec list should contain {count:d} specs")
def step_spec_count(context: Context, count: int) -> None:
assert len(context.lsp_tool_specs) == count, (
f"Expected {count} specs, got {len(context.lsp_tool_specs)}"
)
@then('the tool spec names should include "{name}"')
def step_spec_name_includes(context: Context, name: str) -> None:
names = [s["name"] for s in context.lsp_tool_specs]
assert name in names, f"Tool spec '{name}' not in {names}"
@then("the binding should be valid")
def step_binding_valid(context: Context) -> None:
assert context.lsp_error is None, f"Unexpected error: {context.lsp_error}"
assert hasattr(context, "lsp_binding"), "No binding created"
@then('the binding node_name should be "{name}"')
def step_binding_node(context: Context, name: str) -> None:
assert context.lsp_binding.node_name == name
@then('the binding lsp_server_name should be "{name}"')
def step_binding_server(context: Context, name: str) -> None:
assert context.lsp_binding.lsp_server_name == name
@then("the binding auto_detect should be True")
def step_binding_auto_detect(context: Context) -> None:
assert context.lsp_binding.auto_detect is True
# NOTE: The @then step 'a ValidationError should be raised mentioning ...'
# is defined in config_service_coverage_steps.py (shared step).
@then('the LSP server config language "{lang}" should be present')
def step_config_lang_present(context: Context, lang: str) -> None:
assert context.lsp_config is not None
assert lang in context.lsp_config.languages
@then('the LSP server config namespace should be "{ns}"')
def step_config_namespace(context: Context, ns: str) -> None:
assert context.lsp_config is not None
assert context.lsp_config.namespace == ns
@then('the LSP server config short_name should be "{short}"')
def step_config_short_name(context: Context, short: str) -> None:
assert context.lsp_config is not None
assert context.lsp_config.short_name == short
@then("LspError should be a subclass of CleverAgentsError")
def step_lsp_error_hierarchy(context: Context) -> None:
assert issubclass(LspError, CleverAgentsError)
@then('the error server_name should be "{name}"')
def step_error_server_name(context: Context, name: str) -> None:
assert hasattr(context.lsp_error, "server_name"), "Error has no server_name"
assert context.lsp_error.server_name == name
@then("a ValueError should be raised")
def step_value_error_raised(context: Context) -> None:
assert context.lsp_error is not None, "Expected a ValueError"
assert isinstance(context.lsp_error, ValueError), (
f"Expected ValueError, got {type(context.lsp_error).__name__}"
)