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
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
481 lines
16 KiB
Python
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__}"
|
|
)
|