diff --git a/CHANGELOG.md b/CHANGELOG.md index c346b1e6e..e2bb52577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,16 @@ Fixed CONTRIBUTORS.md alphabetical ordering. Full nox quality gates pass: lint, format, typecheck, unit tests, integration tests, coverage (97%), security scan, dead code detection, docs build, wheel build, and ASV benchmarks. (#495) +- Added minimal LSP server stub with `agents lsp serve` CLI command supporting the + `initialize`, `shutdown`, and `exit` lifecycle handshake over JSON-RPC stdin/stdout + transport with Content-Length header framing. Unsupported methods return `MethodNotFound` + (-32601); requests before `initialize` return `ServerNotInitialized` (-32002); requests + after `shutdown` return `InvalidRequest` (-32600). Transport hardening includes 10 MB + max content-length, 32 max header lines, graceful recovery from malformed messages, + and recursion-depth protection. `LspServer` stores an `AcpLocalFacade` for future + server-mode wiring. `--log-level` flag controls logging verbosity. Includes 48 Behave + BDD scenarios, Robot Framework smoke tests, ASV startup latency benchmarks, and + `docs/reference/lsp_stub.md`. (#203) - Validated M3 acceptance criteria for v3.2.0 milestone closure. All 10 E2E verification tests pass against the final implementation, exercising real CLI command paths (``plan use``, ``plan execute``, ``plan tree``, diff --git a/benchmarks/lsp_stub_bench.py b/benchmarks/lsp_stub_bench.py new file mode 100644 index 000000000..ff7bfc6f2 --- /dev/null +++ b/benchmarks/lsp_stub_bench.py @@ -0,0 +1,121 @@ +"""ASV benchmarks for LSP server stub startup latency. + +Measures the performance of: +- LspServer construction overhead +- Initialize request handling latency +- Full handshake (initialize + shutdown + exit) latency +- MethodNotFound error response latency +""" + +from __future__ import annotations + +import importlib +import io +import json +import sys +from pathlib import Path + +# Ensure the local *source* tree is importable even when ASV has an +# older build of the package installed. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# Force-reload so ASV picks up the source tree version. Reload the +# specific submodule that contains the benchmarked code, not just the +# top-level package. +import cleveragents # noqa: E402 +import cleveragents.lsp.server # noqa: E402 + +importlib.reload(cleveragents) +importlib.reload(cleveragents.lsp.server) + +from cleveragents.lsp.server import LspServer # noqa: E402 + + +def _encode_message(msg: dict) -> bytes: + """Encode a JSON-RPC message with Content-Length framing.""" + payload = json.dumps(msg).encode("utf-8") + header = f"Content-Length: {len(payload)}\r\n\r\n".encode() + return header + payload + + +def _make_streams(*messages: dict) -> tuple[io.BytesIO, io.BytesIO]: + """Build input/output streams from a sequence of JSON-RPC messages.""" + raw = b"".join(_encode_message(msg) for msg in messages) + return io.BytesIO(raw), io.BytesIO() + + +class LspStubStartupSuite: + """Benchmark LSP server stub startup and request handling.""" + + timeout = 60 + + def setup(self) -> None: + """Pre-build common messages and reusable streams for benchmarks.""" + self.init_msg = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"capabilities": {}}, + } + self.shutdown_msg = { + "jsonrpc": "2.0", + "id": 2, + "method": "shutdown", + } + self.exit_msg = { + "jsonrpc": "2.0", + "method": "exit", + } + self.completion_msg = { + "jsonrpc": "2.0", + "id": 3, + "method": "textDocument/completion", + "params": {}, + } + + # Pre-build the 100-message payload so that list construction + # overhead is excluded from the benchmark measurement. + batch_messages: list[dict] = [self.init_msg] + for i in range(100): + batch_messages.append( + { + "jsonrpc": "2.0", + "id": i + 10, + "method": "textDocument/completion", + "params": {}, + } + ) + batch_messages.append(self.exit_msg) + self._batch_raw = b"".join(_encode_message(msg) for msg in batch_messages) + + def time_server_construction(self) -> None: + """Measure LspServer construction overhead.""" + inp, out = _make_streams(self.exit_msg) + LspServer(input_stream=inp, output_stream=out) + + def time_initialize_request(self) -> None: + """Measure initialize request handling latency.""" + inp, out = _make_streams(self.init_msg, self.exit_msg) + server = LspServer(input_stream=inp, output_stream=out) + server.run() + + def time_full_handshake(self) -> None: + """Measure full lifecycle: initialize + shutdown + exit.""" + inp, out = _make_streams(self.init_msg, self.shutdown_msg, self.exit_msg) + server = LspServer(input_stream=inp, output_stream=out) + server.run() + + def time_method_not_found(self) -> None: + """Measure MethodNotFound error response latency (after initialize).""" + inp, out = _make_streams(self.init_msg, self.completion_msg, self.exit_msg) + server = LspServer(input_stream=inp, output_stream=out) + server.run() + + def time_100_method_not_found(self) -> None: + """Measure 100 MethodNotFound responses (after initialize).""" + inp = io.BytesIO(self._batch_raw) + out = io.BytesIO() + server = LspServer(input_stream=inp, output_stream=out) + server.run() diff --git a/docs/reference/lsp_stub.md b/docs/reference/lsp_stub.md new file mode 100644 index 000000000..1b7a2d7dc --- /dev/null +++ b/docs/reference/lsp_stub.md @@ -0,0 +1,184 @@ +# LSP Server Stub + +The CleverAgents LSP server stub provides a minimal Language Server +Protocol implementation that handles the core lifecycle handshake +over JSON-RPC stdin/stdout transport with Content-Length header +framing. + +## Usage + +Launch the stub server via the CLI: + +```bash +agents lsp serve +agents lsp serve --log-level debug +``` + +The server reads JSON-RPC messages from **stdin** and writes +responses to **stdout**, following the LSP base protocol +specification. + +### Options + +| Flag | Default | Description | +|----------------|---------|------------------------------------------| +| `--log-level` | `info` | Logging level: debug, info, warning, error | + +On startup the server prints a banner with its PID and transport +details to the console (stderr), then enters the message loop. + +## Transport Limits + +| Limit | Value | Description | +|-----------------------|---------|-----------------------------------------------------| +| Max Content-Length | 10 MB | Messages exceeding this are rejected (DoS guard) | +| Max header lines | 32 | Headers with more lines are dropped (DoS guard) | + +Messages with a `Content-Length` header exceeding 10 MB +(`MAX_CONTENT_LENGTH`) are skipped with a warning log entry and +the server continues reading the next message. Up to +`MAX_CONTENT_LENGTH` bytes of body are consumed and discarded +in 64 KB chunks so that subsequent messages are not affected by +a transport desync. The discard is capped to prevent a +malicious client from blocking the server with an absurdly +large declared length. + +Header sections with more than 32 lines (`MAX_HEADER_LINES`) +are skipped to prevent denial-of-service from adversarial clients +sending infinite non-empty header lines. Remaining header lines +are drained up to the blank terminator (with a secondary cap of +`MAX_HEADER_LINES` drain lines) so the stream stays aligned for +the next message. + +Malformed `Content-Length` values (non-integer strings) are +rejected and remaining headers are drained before resuming. + +Deeply nested JSON payloads that exceed Python's recursion limit +are caught and return a JSON-RPC parse error response. This +prevents a denial-of-service from adversarial clients sending +pathologically nested structures to exhaust the call stack. + +Other recoverable transport errors — negative `Content-Length`, +incomplete message body, and malformed JSON — are also skipped +(with appropriate warning logs or JSON-RPC parse error responses) +rather than terminating the server. If the output stream is +broken (e.g. client disconnected), the server logs a warning and +terminates gracefully. Only a true EOF (closed stdin), a broken +output pipe, or an ``exit`` notification stops the event loop. + +## Supported Methods + +### `initialize` + +Responds with a stubbed `InitializeResult` containing: + +- **serverInfo**: `{ "name": "cleveragents-lsp-stub", "version": "0.1.0" }` +- **capabilities**: All capabilities from the specification's LSP + Capability Exposure section are reported as stubs (disabled or + null). The capability set includes `textDocumentSync`, + `completionProvider`, `hoverProvider`, `definitionProvider`, + `referencesProvider`, `documentFormattingProvider`, `renameProvider`, + `codeActionProvider`, `diagnosticProvider`, `signatureHelpProvider`, + `documentSymbolProvider`, and `workspaceSymbolProvider`. + +A second `initialize` request is rejected with `InvalidRequest` +(-32600) per the LSP specification. + +### `shutdown` + +Returns a JSON-RPC success response with `result: null`. Marks the +server as ready for exit. After `shutdown`, only `exit` is +accepted — all other requests receive an `InvalidRequest` (-32600) +error, and notifications are silently ignored, per the LSP +specification (section 3.16). + +Like all other requests (except `initialize`), `shutdown` is subject +to the pre-initialize guard: sending `shutdown` before `initialize` +returns a `ServerNotInitialized` (-32002) error. + +### `exit` + +Terminates the server event loop. If `shutdown` was received first +the exit code is 0; otherwise it is 1. + +## Pre-Initialize Requests + +Requests received **before** the `initialize` handshake (including +`shutdown`) return a `ServerNotInitialized` (-32002) error, per the +LSP specification: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32002, + "message": "Server not initialized: textDocument/completion" + } +} +``` + +Notifications sent before `initialize` are silently ignored. + +## Unsupported Methods + +After `initialize`, all other LSP methods (e.g. +`textDocument/didOpen`, `textDocument/completion`, +`textDocument/hover`) return a JSON-RPC `MethodNotFound` error: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32601, + "message": "Not implemented: textDocument/completion" + } +} +``` + +Notifications (messages without an `id` field) for unsupported +methods are silently ignored, per the JSON-RPC specification. + +## ACP Facade Wiring + +An `AcpLocalFacade` instance is stored on `LspServer` so that the +dispatch path can be wired through ACP when server mode lands. +In the **current stub implementation** the facade is a placeholder — +all methods are handled by hardcoded stub handlers in +`_dispatch_stub()`. The facade is **not called** at this time. + +> **TODO** (M7+): Wire `_dispatch_stub()` through `self._facade` +> when server mode is implemented. + +## Architecture + +``` +stdin → [Content-Length framing] → [JSON-RPC parser] + ↓ + [LspServer._handle_message] + ↓ + [_dispatch_stub (hardcoded)] + ↓ + [Stub handlers] + ↓ +stdout ← [Content-Length framing] ← [JSON-RPC response] +``` + +> Note: `AcpLocalFacade` is stored but not called in the current +> stub. The architecture diagram above will change when server +> mode routes through the facade. + +## Future Roadmap + +- **M7**: Wire `textDocument/didOpen` and `textDocument/didClose` + notifications to the ACP context pipeline. +- **M8**: Implement `textDocument/completion` via the LspToolAdapter, + delegating to real language server processes managed by LspRuntime. +- **M9**: Add `textDocument/diagnostics` push support with + server-initiated notifications. +- **M10**: Full LSP feature parity for supported languages + (Python, TypeScript, Go) with hot-reload of language server + configurations from the LSP Registry. +- **Server mode**: Replace stub handlers with real LSP transport + proxying through the ACP facade in server mode. diff --git a/docs/specification.md b/docs/specification.md index 0927b6d00..ea14ef534 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -301,6 +301,7 @@ The following standards are integrated into the architecture: agents lsp remove [--yes|-y] <NAME> agents lsp list [(--namespace|-n) <NS>] [--language <LANG>] agents lsp show <NAME> +agents lsp serve [--log-level <LEVEL>] agents resource type add (--config|-c) <FILE> [--update] agents resource type remove [--yes|-y] <NAME> @@ -9205,6 +9206,52 @@ Show full details for a registered LSP server, including its configuration, supp - LSP server details loaded ``` +##### agents lsp serve + +
agents lsp serve [--log-level <LEVEL>]
+ +**Purpose** +Launch the stub LSP server over stdin/stdout using the JSON-RPC base protocol with Content-Length header framing. The server supports the `initialize`, `shutdown`, and `exit` lifecycle methods. Requests received **before** `initialize` are rejected with `ServerNotInitialized` (-32002) per the LSP specification. After initialization, unsupported LSP methods return a `MethodNotFound` (-32601) error. After `shutdown` is received, only `exit` is accepted; other requests are rejected with `InvalidRequest` (-32600). Sending `initialize` a second time is also rejected with `InvalidRequest`. + +This command is intended for development and testing of LSP transport integration. In a future milestone the stub handlers will be replaced by real LSP server proxying through the ACP facade. + +**Arguments** + +- `--log-level LEVEL`: Logging level for the server process. Valid values: `debug`, `info` (default), `warning`, `error`. Invalid values cause immediate exit with a descriptive error message. + +**Transport Limits** + +| Limit | Value | Description | +|-------|-------|-------------| +| Max Content-Length | 10 MB | Messages exceeding this are rejected with a warning log (DoS guard) | +| Max header lines | 32 | Headers with more lines are rejected with a warning log (DoS guard) | + +**Examples** + +=== "Rich" + +

+    $ agents lsp serve
+
+    CleverAgents LSP Server (stub) starting — PID 7412
+      Log level: info
+      Transport: stdin/stdout (JSON-RPC, Content-Length framing)
+      Supported methods: initialize, shutdown, exit
+      All other methods → MethodNotFound (-32601)
+    
+ +=== "Plain" + + ``` + $ agents lsp serve + + CleverAgents LSP Server (stub) starting — PID 7412 + Log level: info + Transport: stdin/stdout (JSON-RPC, Content-Length framing) + Supported methods: initialize, shutdown, exit + All other methods → MethodNotFound (-32601) + ``` + #### agents validation !!! info "Purpose" diff --git a/features/lsp_server_stub.feature b/features/lsp_server_stub.feature new file mode 100644 index 000000000..44c76cdc6 --- /dev/null +++ b/features/lsp_server_stub.feature @@ -0,0 +1,466 @@ +@phase2 @domain @lsp @lsp_server_stub +Feature: LSP server stub JSON-RPC protocol + As a developer integrating LSP into CleverAgents + I want a minimal LSP server stub that handles core lifecycle methods + So that I can test the JSON-RPC transport and protocol handshake + + Background: + Given a mock LSP transport + + # --------------------------------------------------------------------------- + # Initialize handshake + # --------------------------------------------------------------------------- + + @lsp_initialize + Scenario: LSP initialize returns server info and capabilities + Given an initialize request with id 1 + When the LSP server processes all messages + Then the response for id 1 should have result + And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub" + And the result for id 1 should contain serverInfo with version "0.1.0" + And the result for id 1 should contain capabilities key "textDocumentSync" + + @lsp_initialize + Scenario: LSP initialize with client info is accepted + Given an initialize request with id 10 and clientInfo name "test-client" + When the LSP server processes all messages + Then the response for id 10 should have result + And the result for id 10 should contain serverInfo with name "cleveragents-lsp-stub" + + @lsp_initialize + Scenario: Initialized notification is silently accepted + Given an initialize request with id 1 + And a notification for method "initialized" + And an exit notification + When the LSP server processes all messages + Then the response for id 1 should have result + And there should be no error responses + + @lsp_initialize + Scenario: Initialized notification before initialize is silently ignored + Given a notification for method "initialized" + And an exit notification + When the LSP server processes all messages + Then there should be no error responses + And the server exit code should be 1 + + # --------------------------------------------------------------------------- + # Shutdown handshake + # --------------------------------------------------------------------------- + + @lsp_shutdown + Scenario: LSP shutdown returns null result + Given an initialize request with id 1 + And a shutdown request with id 2 + And an exit notification + When the LSP server processes all messages + Then the response for id 2 should have null result + And the server exit code should be 0 + + @lsp_shutdown + Scenario: LSP exit without prior shutdown returns exit code 1 + Given an initialize request with id 1 + And an exit notification + When the LSP server processes all messages + Then the server exit code should be 1 + + # --------------------------------------------------------------------------- + # Unsupported methods + # --------------------------------------------------------------------------- + + @lsp_unsupported + Scenario: Unsupported method after initialize returns MethodNotFound error + Given an initialize request with id 1 + And a request for method "textDocument/completion" with id 5 + And an exit notification + When the LSP server processes all messages + Then the response for id 5 should have error code -32601 + And the error message for id 5 should contain "Not implemented: textDocument/completion" + + @lsp_unsupported + Scenario: Unsupported method textDocument/hover after initialize returns error + Given an initialize request with id 1 + And a request for method "textDocument/hover" with id 6 + And an exit notification + When the LSP server processes all messages + Then the response for id 6 should have error code -32601 + And the error message for id 6 should contain "Not implemented: textDocument/hover" + + @lsp_unsupported + Scenario: Unsupported method before initialize returns ServerNotInitialized + Given a request for method "textDocument/completion" with id 5 + And an exit notification + When the LSP server processes all messages + Then the response for id 5 should have error code -32002 + And the error message for id 5 should contain "Server not initialized" + + @lsp_unsupported + Scenario: Unsupported notification is silently ignored + Given a notification for method "textDocument/didOpen" + And an exit notification + When the LSP server processes all messages + Then there should be no error responses + + # --------------------------------------------------------------------------- + # Invalid JSON-RPC messages + # --------------------------------------------------------------------------- + + @lsp_invalid + Scenario: Invalid JSON returns parse error response + Given raw bytes that are not valid JSON-RPC + And an exit notification + When the LSP server processes all messages + Then a parse error response should have been sent + And the server should have stopped + + @lsp_invalid + Scenario: Missing jsonrpc version field returns error + Given a message with missing jsonrpc version and id 7 + When the LSP server processes all messages + Then the response for id 7 should have error code -32600 + And the error message for id 7 should contain "jsonrpc version" + + @lsp_invalid + Scenario: Missing method field returns error + Given a message with missing method and id 8 + When the LSP server processes all messages + Then the response for id 8 should have error code -32600 + And the error message for id 8 should contain "method" + + # --------------------------------------------------------------------------- + # Server lifecycle + # --------------------------------------------------------------------------- + + @lsp_lifecycle + Scenario: Server startup reports PID + Given an initialize request with id 1 + And an exit notification + When the LSP server processes all messages + Then the server should have logged its PID + + @lsp_lifecycle + Scenario: Server handles EOF gracefully + When the LSP server processes all messages with empty input + Then the server should have stopped + And the server exit code should be 1 + + # --------------------------------------------------------------------------- + # ACP facade wiring + # --------------------------------------------------------------------------- + + @lsp_acp + Scenario: LSP server stores provided ACP facade + Given an LSP server with ACP facade + And an initialize request with id 1 + When the LSP server processes all messages + Then the response for id 1 should have result + And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub" + And the server should hold the provided ACP facade + + @lsp_acp + Scenario: LSP server lazily creates ACP facade on property access + Given an initialize request with id 1 + And an exit notification + When the LSP server processes all messages + Then the response for id 1 should have result + And the server facade should be lazily created + + # --------------------------------------------------------------------------- + # Full handshake sequence + # --------------------------------------------------------------------------- + + @lsp_full_handshake + Scenario: Complete LSP lifecycle (initialize, shutdown, exit) + Given an initialize request with id 1 + And a shutdown request with id 2 + And an exit notification + When the LSP server processes all messages + Then the response for id 1 should have result + And the response for id 2 should have null result + And the server exit code should be 0 + + # --------------------------------------------------------------------------- + # Server construction validation + # --------------------------------------------------------------------------- + + @lsp_construction + Scenario: LspServer rejects invalid input_stream + When I try to create an LspServer with invalid input stream + Then a TypeError should be raised for input stream + + @lsp_construction + Scenario: LspServer rejects invalid output_stream + When I try to create an LspServer with invalid output stream + Then a TypeError should be raised for output stream + + # --------------------------------------------------------------------------- + # Transport edge cases + # --------------------------------------------------------------------------- + + @lsp_transport_edge + Scenario: Server recovers from invalid message and handles subsequent request + Given a message with missing jsonrpc version and id 7 + And an initialize request with id 1 + And an exit notification + When the LSP server processes all messages + Then the response for id 7 should have error code -32600 + And the response for id 1 should have result + And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub" + + @lsp_transport_edge + Scenario: Request with non-dict params uses empty params + Given an initialize request with id 1 + And a request with non-dict params and id 20 + And an exit notification + When the LSP server processes all messages + Then the response for id 20 should have error code -32601 + + @lsp_transport_edge + Scenario: Negative content length is handled gracefully + Given a message with negative content length + When the LSP server processes all messages + Then the server should have stopped + And the server exit code should be 1 + And a transport warning should have been logged for "lsp.transport.negative_content_length" + + @lsp_transport_edge + Scenario: Incomplete message body is handled gracefully + Given a message with incomplete body + When the LSP server processes all messages + Then the server should have stopped + And the server exit code should be 1 + And a transport warning should have been logged for "lsp.transport.incomplete_read" + + @lsp_transport_edge + Scenario: Unknown method returns MethodNotFound without data field + Given an initialize request with id 1 + And a request for method "custom/withData" with id 30 + And an exit notification + When the LSP server processes all messages + Then the response for id 30 should have error code -32601 + And the error response for id 30 should not contain a data field + + @lsp_transport_edge + Scenario: Missing content length header is handled + Given a message with missing content length header + When the LSP server processes all messages + Then the server should have stopped + And the server exit code should be 1 + And a transport warning should have been logged for "lsp.transport.missing_content_length" + + @lsp_transport_edge + Scenario: Oversized content length is rejected + Given a message with content length exceeding the maximum + And an exit notification + When the LSP server processes all messages + Then the server should have stopped + And the server exit code should be 1 + And a transport warning should have been logged for "lsp.transport.content_length_exceeded" + + @lsp_transport_edge + Scenario: Too many header lines are rejected + Given a message with too many header lines + When the LSP server processes all messages + Then the server should have stopped + And the server exit code should be 1 + And a transport warning should have been logged for "lsp.transport.too_many_header_lines" + + @lsp_transport_edge + Scenario: JSON array body returns InvalidRequest error + Given a message with a JSON array body + And an exit notification + When the LSP server processes all messages + Then an InvalidRequest error response should have been sent + + @lsp_transport_edge + Scenario: Non-integer content length is handled gracefully + Given a message with non-integer content length + When the LSP server processes all messages + Then the server should have stopped + And the server exit code should be 1 + And a transport warning should have been logged for "lsp.transport.invalid_content_length" + + @lsp_transport_edge + Scenario: Zero content length returns parse error + Given a message with content length zero + And an exit notification + When the LSP server processes all messages + Then a parse error response should have been sent + And the server should have stopped + + @lsp_transport_edge + Scenario: Duplicate content-length headers uses last value + Given a message with duplicate content-length headers and id 60 + And an exit notification + When the LSP server processes all messages + Then the response for id 60 should have result + And the result for id 60 should contain serverInfo with name "cleveragents-lsp-stub" + + @lsp_transport_edge + Scenario: Shutdown without prior initialize returns ServerNotInitialized + Given a shutdown request with id 1 + And an exit notification + When the LSP server processes all messages + Then the response for id 1 should have error code -32002 + And the error message for id 1 should contain "Server not initialized" + + @lsp_transport_edge + Scenario: Initialize without params key uses empty params + Given a message with initialize request without params key and id 50 + And an exit notification + When the LSP server processes all messages + Then the response for id 50 should have result + And the result for id 50 should contain serverInfo with name "cleveragents-lsp-stub" + + # --------------------------------------------------------------------------- + # Post-shutdown request rejection (LSP spec §3.16) + # --------------------------------------------------------------------------- + + @lsp_shutdown + Scenario: Request after shutdown returns InvalidRequest error + Given an initialize request with id 1 + And a shutdown request with id 2 + And a request for method "textDocument/completion" with id 3 + And an exit notification + When the LSP server processes all messages + Then the response for id 3 should have error code -32600 + And the error message for id 3 should contain "shutting down" + + @lsp_shutdown + Scenario: Notification after shutdown is silently ignored + Given an initialize request with id 1 + And a shutdown request with id 2 + And a notification for method "textDocument/didOpen" + And an exit notification + When the LSP server processes all messages + Then the response for id 1 should have result + And the response for id 2 should have null result + And there should be no error responses + And the server exit code should be 0 + + # --------------------------------------------------------------------------- + # Double initialize rejection + # --------------------------------------------------------------------------- + + @lsp_initialize + Scenario: Second initialize request returns InvalidRequest error + Given an initialize request with id 1 + And an initialize request with id 2 + And an exit notification + When the LSP server processes all messages + Then the response for id 1 should have result + And the response for id 2 should have error code -32600 + And the error message for id 2 should contain "already initialized" + + # --------------------------------------------------------------------------- + # Stream desync recovery (transport error followed by valid message) + # --------------------------------------------------------------------------- + + @lsp_transport_edge + Scenario: Server recovers after oversized message with body and processes next request + Given an oversized message with real body followed by an initialize request with id 1 + And an exit notification + When the LSP server processes all messages + Then the response for id 1 should have result + And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub" + + @lsp_transport_edge + Scenario: Server recovers after invalid content-length header and processes next request + Given a message with non-integer content length followed by an initialize request with id 1 + And an exit notification + When the LSP server processes all messages + Then the response for id 1 should have result + And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub" + + @lsp_transport_edge + Scenario: Server recovers after too many header lines and processes next request + Given a message with too many header lines followed by an initialize request with id 1 + And an exit notification + When the LSP server processes all messages + Then the response for id 1 should have result + And the result for id 1 should contain serverInfo with name "cleveragents-lsp-stub" + + @lsp_transport_edge + Scenario: EOF during body discard of oversized message is handled gracefully + Given an oversized message with a short body that triggers EOF during discard + When the LSP server processes all messages + Then the server should have stopped + And the server exit code should be 1 + And a transport warning should have been logged for "lsp.transport.content_length_exceeded" + + @lsp_transport_edge + Scenario: Oversized single header line is handled gracefully + Given a message with a header line exceeding the maximum line length + And an exit notification + When the LSP server processes all messages + Then the server should have stopped + And the server exit code should be 1 + And a transport warning should have been logged for "lsp.transport.too_many_header_lines" + + @lsp_transport_edge + Scenario: Deeply nested JSON triggers parse error response + Given a message with deeply nested JSON + And an exit notification + When the LSP server processes all messages + Then a parse error response should have been sent + And a transport warning should have been logged for "lsp.transport.json_recursion_error" + And the server should have stopped + + # --------------------------------------------------------------------------- + # CLI serve command + # --------------------------------------------------------------------------- + + @lsp_cli_serve + Scenario: CLI serve command rejects invalid log level + When I run lsp CLI serve with log level "banana" + Then the lsp serve CLI should have failed + And the lsp serve CLI output should contain "Invalid log level" + + @lsp_cli_serve + Scenario: CLI serve command accepts valid log level and runs + When I run lsp CLI serve with piped empty stdin + Then the lsp serve CLI should have exited with code 1 + And the lsp serve CLI output should contain "CleverAgents LSP Server" + + @lsp_cli_serve + Scenario: CLI serve command with debug log level + When I run lsp CLI serve with log level "debug" and piped empty stdin + Then the lsp serve CLI should have exited with code 1 + And the lsp serve CLI output should contain "CleverAgents LSP Server" + + # --------------------------------------------------------------------------- + # Broken output stream (pipe failure) + # --------------------------------------------------------------------------- + + @lsp_transport_edge + Scenario: Broken output stream stops the server gracefully + Given an initialize request with id 1 + And an exit notification + When the LSP server processes all messages with a broken output stream + Then the server should have stopped + And the server exit code should be 1 + And a transport warning should have been logged for "lsp.transport.write_error" + + # --------------------------------------------------------------------------- + # String-valued JSON-RPC request id + # --------------------------------------------------------------------------- + + @lsp_initialize + Scenario: String request id is echoed correctly + Given an initialize request with string id "alpha-1" + And an exit notification + When the LSP server processes all messages + Then the response for string id "alpha-1" should have result + And the result for string id "alpha-1" should contain serverInfo with name "cleveragents-lsp-stub" + + # --------------------------------------------------------------------------- + # EOF after shutdown without exit notification + # --------------------------------------------------------------------------- + + @lsp_shutdown + Scenario: EOF after shutdown without exit returns exit code 0 + Given an initialize request with id 1 + And a shutdown request with id 2 + When the LSP server processes all messages + Then the response for id 2 should have null result + And the server exit code should be 0 diff --git a/features/mocks/__init__.py b/features/mocks/__init__.py index 038b9843c..4e144fc97 100644 --- a/features/mocks/__init__.py +++ b/features/mocks/__init__.py @@ -5,7 +5,13 @@ Following the mock placement rule, all mocks must exist only in the features/ directory and never in production code (implementation_plan.md). """ +from .lsp_transport_mock import MockLspTransport, parse_lsp_responses from .mock_ai_provider import MockAIProvider from .mock_mcp_transport import MockMCPTransport -__all__ = ["MockAIProvider", "MockMCPTransport"] +__all__ = [ + "MockAIProvider", + "MockLspTransport", + "MockMCPTransport", + "parse_lsp_responses", +] diff --git a/features/mocks/lsp_transport_mock.py b/features/mocks/lsp_transport_mock.py new file mode 100644 index 000000000..bcf85f521 --- /dev/null +++ b/features/mocks/lsp_transport_mock.py @@ -0,0 +1,158 @@ +"""Mock stdin/stdout transport for LSP server testing. + +Provides :class:`MockLspTransport` which simulates the +Content-Length framed JSON-RPC transport used by the LSP server +without requiring a real subprocess. + +The :func:`parse_lsp_responses` utility is shared with the Robot +Framework helper to avoid duplicating response-parsing logic. + +All mocks reside in ``features/mocks/`` per project conventions. +""" + +from __future__ import annotations + +import io +import json +from typing import Any + + +def parse_lsp_responses(raw: bytes) -> list[dict[str, Any]]: + """Parse Content-Length framed JSON-RPC responses from raw bytes. + + Shared utility used by :class:`MockLspTransport` and the Robot + Framework helper to avoid duplicating the parsing logic. + + Args: + raw: The raw bytes containing one or more Content-Length + framed JSON-RPC response messages. + + Returns: + List of parsed JSON-RPC response dicts. + """ + responses: list[dict[str, Any]] = [] + offset = 0 + while offset < len(raw): + header_end = raw.find(b"\r\n\r\n", offset) + if header_end == -1: + break + header_section = raw[offset:header_end].decode("utf-8", errors="replace") + content_length: int | None = None + for line in header_section.split("\r\n"): + if line.lower().startswith("content-length:"): + content_length = int(line.split(":", 1)[1].strip()) + if content_length is None: + break + body_start = header_end + 4 + body_end = body_start + content_length + body = raw[body_start:body_end] + responses.append(json.loads(body)) + offset = body_end + return responses + + +class MockLspTransport: + """Simulated LSP stdin/stdout binary streams. + + Encodes JSON-RPC messages with Content-Length framing into a + binary input buffer and captures binary output for assertion. + + Usage:: + + transport = MockLspTransport() + transport.send_request("initialize", {"capabilities": {}}, id=1) + server = LspServer( + input_stream=transport.input_stream, + output_stream=transport.output_stream, + ) + server.run() + responses = transport.read_responses() + """ + + def __init__(self) -> None: + self._input_buf = io.BytesIO() + self._output_buf = io.BytesIO() + + def send_request( + self, + method: str, + params: dict[str, Any] | None = None, + request_id: int | str | None = 1, + ) -> None: + """Append a JSON-RPC request to the input buffer. + + Args: + method: The JSON-RPC method name. + params: Optional parameters dict. + request_id: The request id (``None`` for notifications). + """ + msg: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if request_id is not None: + msg["id"] = request_id + if params is not None: + msg["params"] = params + self._append_message(msg) + + def send_notification( + self, + method: str, + params: dict[str, Any] | None = None, + ) -> None: + """Append a JSON-RPC notification (no id) to the input buffer. + + Args: + method: The JSON-RPC method name. + params: Optional parameters dict. + """ + self.send_request(method, params, request_id=None) + + def send_raw(self, raw_bytes: bytes) -> None: + """Append raw bytes to the input buffer. + + Useful for testing malformed messages. + + Args: + raw_bytes: The raw bytes to append. + """ + pos = self._input_buf.tell() + self._input_buf.seek(0, 2) + self._input_buf.write(raw_bytes) + self._input_buf.seek(pos) + + def _append_message(self, msg: dict[str, Any]) -> None: + """Encode and append a Content-Length framed message.""" + payload = json.dumps(msg).encode("utf-8") + header = f"Content-Length: {len(payload)}\r\n\r\n".encode() + pos = self._input_buf.tell() + self._input_buf.seek(0, 2) + self._input_buf.write(header + payload) + self._input_buf.seek(pos) + + @property + def input_stream(self) -> io.BytesIO: + """Return the input stream positioned at the start.""" + self._input_buf.seek(0) + return self._input_buf + + @property + def output_stream(self) -> io.BytesIO: + """Return the output stream for writing.""" + return self._output_buf + + def read_responses(self) -> list[dict[str, Any]]: + """Parse all JSON-RPC responses from the output buffer. + + Returns: + List of parsed JSON-RPC response dicts. + """ + self._output_buf.seek(0) + raw = self._output_buf.read() + if not raw: + return [] + return parse_lsp_responses(raw) + + +__all__ = [ + "MockLspTransport", + "parse_lsp_responses", +] diff --git a/features/steps/lsp_server_stub_steps.py b/features/steps/lsp_server_stub_steps.py new file mode 100644 index 000000000..f5ea7cb09 --- /dev/null +++ b/features/steps/lsp_server_stub_steps.py @@ -0,0 +1,717 @@ +"""Step definitions for LSP server stub BDD tests. + +Covers the JSON-RPC transport, protocol handshake, error handling, +ACP facade wiring, and CLI serve command for the LSP server stub. +""" + +from __future__ import annotations + +import io +import json +from typing import Any + +import structlog +from behave import given, then, when +from behave.runner import Context +from typer.testing import CliRunner + +from cleveragents.acp.facade import AcpLocalFacade +from cleveragents.cli.commands.lsp import app as lsp_app +from cleveragents.lsp.server import ( + MAX_CONTENT_LENGTH, + MAX_HEADER_LINE_LENGTH, + MAX_HEADER_LINES, + LspServer, +) +from features.mocks.lsp_transport_mock import MockLspTransport + +_cli_runner = CliRunner() + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a mock LSP transport") +def step_mock_transport(context: Context) -> None: + context.lsp_transport = MockLspTransport() + context.lsp_server: LspServer | None = None + context.lsp_responses: list[dict[str, Any]] = [] + context.lsp_exit_code: int | None = None + context.lsp_facade: AcpLocalFacade | None = None + context.lsp_error: Exception | None = None + + +# --------------------------------------------------------------------------- +# Given — messages +# --------------------------------------------------------------------------- + + +@given("an initialize request with id {req_id:d}") +def step_initialize_request(context: Context, req_id: int) -> None: + context.lsp_transport.send_request( + "initialize", + {"capabilities": {}}, + request_id=req_id, + ) + + +@given('an initialize request with id {req_id:d} and clientInfo name "{name}"') +def step_initialize_with_client_info(context: Context, req_id: int, name: str) -> None: + context.lsp_transport.send_request( + "initialize", + {"capabilities": {}, "clientInfo": {"name": name}}, + request_id=req_id, + ) + + +@given("a shutdown request with id {req_id:d}") +def step_shutdown_request(context: Context, req_id: int) -> None: + context.lsp_transport.send_request("shutdown", request_id=req_id) + + +@given("an exit notification") +def step_exit_notification(context: Context) -> None: + context.lsp_transport.send_notification("exit") + + +@given('a request for method "{method}" with id {req_id:d}') +def step_request_method(context: Context, method: str, req_id: int) -> None: + context.lsp_transport.send_request(method, {}, request_id=req_id) + + +@given('a notification for method "{method}"') +def step_notification_method(context: Context, method: str) -> None: + context.lsp_transport.send_notification(method) + + +@given("raw bytes that are not valid JSON-RPC") +def step_raw_invalid_bytes(context: Context) -> None: + bad_payload = b"this is not json at all" + framed = f"Content-Length: {len(bad_payload)}\r\n\r\n".encode() + bad_payload + context.lsp_transport.send_raw(framed) + + +@given("a message with missing jsonrpc version and id {req_id:d}") +def step_missing_jsonrpc_version(context: Context, req_id: int) -> None: + msg = {"id": req_id, "method": "test"} + payload = json.dumps(msg).encode("utf-8") + framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload + context.lsp_transport.send_raw(framed) + + +@given("a message with missing method and id {req_id:d}") +def step_missing_method(context: Context, req_id: int) -> None: + msg = {"jsonrpc": "2.0", "id": req_id} + payload = json.dumps(msg).encode("utf-8") + framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload + context.lsp_transport.send_raw(framed) + + +@given("an LSP server with ACP facade") +def step_server_with_facade(context: Context) -> None: + context.lsp_facade = AcpLocalFacade() + + +# --------------------------------------------------------------------------- +# When — server execution +# --------------------------------------------------------------------------- + + +@when("the LSP server processes all messages") +def step_process_messages(context: Context) -> None: + facade = getattr(context, "lsp_facade", None) + server = LspServer( + input_stream=context.lsp_transport.input_stream, + output_stream=context.lsp_transport.output_stream, + facade=facade, + ) + context.lsp_server = server + with structlog.testing.capture_logs() as captured: + context.lsp_exit_code = server.run() + context.lsp_captured_logs = captured + context.lsp_responses = context.lsp_transport.read_responses() + + +@when("the LSP server processes all messages with empty input") +def step_process_empty(context: Context) -> None: + empty_input = io.BytesIO(b"") + output_buf = io.BytesIO() + server = LspServer(input_stream=empty_input, output_stream=output_buf) + context.lsp_server = server + context.lsp_exit_code = server.run() + context.lsp_responses = [] + + +@when("the LSP server processes all messages with a broken output stream") +def step_process_broken_output(context: Context) -> None: + """Run the server with an output stream that raises OSError on write.""" + + class _BrokenOutput: + """Mock output stream that raises on any write.""" + + def write(self, _data: bytes) -> int: + raise OSError("broken pipe") + + def flush(self) -> None: + raise OSError("broken pipe") + + server = LspServer( + input_stream=context.lsp_transport.input_stream, + output_stream=_BrokenOutput(), # type: ignore[arg-type] + ) + context.lsp_server = server + with structlog.testing.capture_logs() as captured: + context.lsp_exit_code = server.run() + context.lsp_captured_logs = captured + context.lsp_responses = [] + + +@when("I try to create an LspServer with invalid input stream") +def step_invalid_input_stream(context: Context) -> None: + try: + LspServer(input_stream="not a stream") # type: ignore[arg-type] + context.lsp_error = None + except TypeError as exc: + context.lsp_error = exc + + +@when("I try to create an LspServer with invalid output stream") +def step_invalid_output_stream(context: Context) -> None: + try: + LspServer(output_stream=42) # type: ignore[arg-type] + context.lsp_error = None + except TypeError as exc: + context.lsp_error = exc + + +# --------------------------------------------------------------------------- +# Then — response assertions +# --------------------------------------------------------------------------- + + +def _find_response(context: Context, req_id: int) -> dict[str, Any]: + for resp in context.lsp_responses: + if resp.get("id") == req_id: + return resp + raise AssertionError( + f"No response with id={req_id} found in {context.lsp_responses}" + ) + + +def _find_response_any( + context: Context, + req_id: int | str, +) -> dict[str, Any]: + """Find a response by id, supporting both int and str ids.""" + for resp in context.lsp_responses: + if resp.get("id") == req_id: + return resp + raise AssertionError( + f"No response with id={req_id!r} found in {context.lsp_responses}" + ) + + +@then("the response for id {req_id:d} should have result") +def step_response_has_result(context: Context, req_id: int) -> None: + resp = _find_response(context, req_id) + assert "result" in resp, f"Response {resp} missing 'result'" + assert resp["result"] is not None, f"Response result is None: {resp}" + + +@then("the response for id {req_id:d} should have null result") +def step_response_null_result(context: Context, req_id: int) -> None: + resp = _find_response(context, req_id) + assert "result" in resp, f"Response {resp} missing 'result'" + assert resp["result"] is None, f"Expected null result, got {resp['result']}" + + +@then('the result for id {req_id:d} should contain serverInfo with name "{name}"') +def step_result_server_name(context: Context, req_id: int, name: str) -> None: + resp = _find_response(context, req_id) + result = resp.get("result", {}) + server_info = result.get("serverInfo", {}) + assert server_info.get("name") == name, ( + f"Expected serverInfo.name={name}, got {server_info}" + ) + + +@then('the result for id {req_id:d} should contain serverInfo with version "{version}"') +def step_result_server_version(context: Context, req_id: int, version: str) -> None: + resp = _find_response(context, req_id) + result = resp.get("result", {}) + server_info = result.get("serverInfo", {}) + assert server_info.get("version") == version, ( + f"Expected version={version}, got {server_info}" + ) + + +@then('the result for id {req_id:d} should contain capabilities key "{key}"') +def step_result_capabilities_key(context: Context, req_id: int, key: str) -> None: + resp = _find_response(context, req_id) + result = resp.get("result", {}) + caps = result.get("capabilities", {}) + assert key in caps, f"Capabilities missing key '{key}': {caps}" + + +@then("the response for id {req_id:d} should have error code {code:d}") +def step_response_error_code(context: Context, req_id: int, code: int) -> None: + resp = _find_response(context, req_id) + assert "error" in resp, f"Response {resp} missing 'error'" + assert resp["error"]["code"] == code, ( + f"Expected error code {code}, got {resp['error']['code']}" + ) + + +@then('the error message for id {req_id:d} should contain "{text}"') +def step_error_message_for_id(context: Context, req_id: int, text: str) -> None: + resp = _find_response(context, req_id) + error = resp.get("error") + assert error is not None, f"Response for id={req_id} has no error: {resp}" + msg = error.get("message", "") + assert text in msg, ( + f"Error message for id={req_id} does not contain '{text}': {msg}" + ) + + +@then("a parse error response should have been sent") +def step_parse_error_sent(context: Context) -> None: + errors = [ + r + for r in context.lsp_responses + if "error" in r and r["error"].get("code") == -32700 + ] + assert errors, f"No parse error (-32700) response found in {context.lsp_responses}" + + +@then("there should be no error responses") +def step_no_error_responses(context: Context) -> None: + errors = [r for r in context.lsp_responses if "error" in r] + assert not errors, f"Unexpected error responses: {errors}" + + +@then("the server exit code should be {code:d}") +def step_exit_code(context: Context, code: int) -> None: + assert context.lsp_exit_code == code, ( + f"Expected exit code {code}, got {context.lsp_exit_code}" + ) + + +@then("the server should have stopped") +def step_server_stopped(context: Context) -> None: + assert context.lsp_server is not None, "No server was created" + assert context.lsp_server.is_running is False, "Server is still running" + + +@then('a transport warning should have been logged for "{event}"') +def step_transport_warning_logged(context: Context, event: str) -> None: + captured = getattr(context, "lsp_captured_logs", []) + matches = [ + e + for e in captured + if e.get("event") == event and e.get("log_level", "") == "warning" + ] + assert matches, f"No warning log with event={event!r} found in {captured}" + + +@then("the server should have logged its PID") +def step_server_logged_pid(context: Context) -> None: + assert context.lsp_server is not None, "No server was created" + assert context.lsp_exit_code is not None, "Server did not complete" + captured = getattr(context, "lsp_captured_logs", []) + pid_entries = [ + e for e in captured if "pid" in e and e.get("event") == "lsp.server.starting" + ] + assert pid_entries, ( + f"No log entry with PID found for 'lsp.server.starting' in {captured}" + ) + assert isinstance(pid_entries[0]["pid"], int), "PID should be an integer" + + +@then("an InvalidRequest error response should have been sent") +def step_invalid_request_sent(context: Context) -> None: + errors = [ + r + for r in context.lsp_responses + if "error" in r and r["error"].get("code") == -32600 + ] + assert errors, ( + f"No InvalidRequest (-32600) response found in {context.lsp_responses}" + ) + + +@then("the error response for id {req_id:d} should not contain a data field") +def step_error_no_data(context: Context, req_id: int) -> None: + resp = _find_response(context, req_id) + assert "error" in resp, f"Response {resp} missing 'error'" + assert "data" not in resp["error"], ( + f"Error response unexpectedly contains 'data': {resp['error']}" + ) + + +@then("the server should hold the provided ACP facade") +def step_server_holds_facade(context: Context) -> None: + assert context.lsp_server is not None, "No server was created" + assert context.lsp_facade is not None, "No facade was provided" + assert context.lsp_server.facade is context.lsp_facade, ( + "Server does not hold the provided ACP facade instance" + ) + + +@then("the server facade should be lazily created") +def step_server_facade_lazy(context: Context) -> None: + assert context.lsp_server is not None, "No server was created" + # Access the public property — this triggers lazy creation + facade = context.lsp_server.facade + assert facade is not None, "Facade was not lazily created" + from cleveragents.acp.facade import AcpLocalFacade + + assert isinstance(facade, AcpLocalFacade), ( + f"Expected AcpLocalFacade, got {type(facade).__name__}" + ) + + +@then("a TypeError should be raised for input stream") +def step_type_error_input(context: Context) -> None: + assert context.lsp_error is not None, "Expected TypeError" + assert isinstance(context.lsp_error, TypeError), ( + f"Expected TypeError, got {type(context.lsp_error).__name__}" + ) + assert "input_stream" in str(context.lsp_error) + + +@then("a TypeError should be raised for output stream") +def step_type_error_output(context: Context) -> None: + assert context.lsp_error is not None, "Expected TypeError" + assert isinstance(context.lsp_error, TypeError), ( + f"Expected TypeError, got {type(context.lsp_error).__name__}" + ) + assert "output_stream" in str(context.lsp_error) + + +# --------------------------------------------------------------------------- +# Transport edge case steps +# --------------------------------------------------------------------------- + + +@given("a request with non-dict params and id {req_id:d}") +def step_non_dict_params(context: Context, req_id: int) -> None: + msg: dict[str, Any] = { + "jsonrpc": "2.0", + "id": req_id, + "method": "textDocument/completion", + "params": "not-a-dict", + } + payload = json.dumps(msg).encode("utf-8") + framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload + context.lsp_transport.send_raw(framed) + + +@given("a message with a JSON array body") +def step_json_array_body(context: Context) -> None: + # Send a valid JSON array instead of a JSON object so that + # _handle_message's isinstance(msg, dict) guard is exercised. + payload = json.dumps([1, "not", "a", "dict"]).encode("utf-8") + framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload + context.lsp_transport.send_raw(framed) + + +@given("a message with non-integer content length") +def step_non_integer_content_length(context: Context) -> None: + raw = b"Content-Length: abc\r\n\r\n" + context.lsp_transport.send_raw(raw) + + +@given("a message with initialize request without params key and id {req_id:d}") +def step_initialize_no_params(context: Context, req_id: int) -> None: + msg: dict[str, Any] = { + "jsonrpc": "2.0", + "id": req_id, + "method": "initialize", + } + payload = json.dumps(msg).encode("utf-8") + framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload + context.lsp_transport.send_raw(framed) + + +@given("a message with negative content length") +def step_negative_content_length(context: Context) -> None: + raw = b"Content-Length: -5\r\n\r\n" + context.lsp_transport.send_raw(raw) + + +@given("a message with incomplete body") +def step_incomplete_body(context: Context) -> None: + # Declare 100 bytes but only provide 5 + raw = b"Content-Length: 100\r\n\r\nhello" + context.lsp_transport.send_raw(raw) + + +@given("a message with missing content length header") +def step_missing_content_length(context: Context) -> None: + raw = b"Content-Type: application/json\r\n\r\n{}" + context.lsp_transport.send_raw(raw) + + +@given("a message with content length exceeding the maximum") +def step_oversized_content_length(context: Context) -> None: + # Declare a Content-Length above the limit. The *actual* body is + # ``MAX_CONTENT_LENGTH`` bytes — matching the server's capped + # discard — so ``_discard_body`` consumes the entire body and + # subsequent messages (the exit notification) are not corrupted. + declared_length = MAX_CONTENT_LENGTH + 64 + body = b"X" * MAX_CONTENT_LENGTH + raw = f"Content-Length: {declared_length}\r\n\r\n".encode() + body + context.lsp_transport.send_raw(raw) + + +@given("a message with content length zero") +def step_zero_content_length(context: Context) -> None: + raw = b"Content-Length: 0\r\n\r\n" + context.lsp_transport.send_raw(raw) + + +@given("a message with duplicate content-length headers and id {req_id:d}") +def step_duplicate_content_length(context: Context, req_id: int) -> None: + # Build an initialize request. The first Content-Length is wrong + # (too small), but the server takes the *last* value so the + # message is parsed correctly. + msg: dict[str, Any] = { + "jsonrpc": "2.0", + "id": req_id, + "method": "initialize", + "params": {"capabilities": {}}, + } + payload = json.dumps(msg).encode("utf-8") + header = (f"Content-Length: 1\r\nContent-Length: {len(payload)}\r\n\r\n").encode() + context.lsp_transport.send_raw(header + payload) + + +@given("a message with deeply nested JSON") +def step_deeply_nested_json(context: Context) -> None: + """Build a Content-Length framed message whose body is a deeply nested + JSON array (10 000 levels). When ``json.loads`` attempts to parse + this payload it triggers a ``RecursionError``, exercising the guard + in ``_read_message``. + """ + depth = 10000 + payload = ("[" * depth + "]" * depth).encode("utf-8") + framed = f"Content-Length: {len(payload)}\r\n\r\n".encode() + payload + context.lsp_transport.send_raw(framed) + + +@given("an oversized message with a short body that triggers EOF during discard") +def step_oversized_short_body_eof(context: Context) -> None: + """Build a message whose declared CL exceeds the limit but whose actual + body is much shorter than the discard cap. This forces + ``_discard_body`` to hit EOF mid-read (the early-exit ``break`` + inside ``LspServer._discard_body``'s read loop — + ``cleveragents.lsp.server.LspServer._discard_body``), covering + the early-exit path. + """ + declared_length = MAX_CONTENT_LENGTH + 64 + # Only provide a tiny body — far less than the capped discard amount + body = b"X" * 128 + raw = f"Content-Length: {declared_length}\r\n\r\n".encode() + body + context.lsp_transport.send_raw(raw) + + +@given("a message with too many header lines") +def step_too_many_header_lines(context: Context) -> None: + lines = [f"X-Spam-{i}: value\r\n" for i in range(MAX_HEADER_LINES + 1)] + raw = "".join(lines).encode() + context.lsp_transport.send_raw(raw) + + +@given("a message with a header line exceeding the maximum line length") +def step_oversized_header_line(context: Context) -> None: + """Build a single header line that exceeds ``MAX_HEADER_LINE_LENGTH``. + + When ``readline(MAX_HEADER_LINE_LENGTH)`` is used the oversized + line is returned in multiple chunks, each consuming one slot in + the ``MAX_HEADER_LINES`` budget. Eventually the header-flood + guard fires, exercising the ``readline()`` per-line size cap + path described in ``cleveragents.lsp.server.LspServer._read_message``. + """ + # A single line without a newline that far exceeds the per-line + # limit. ``readline(MAX_HEADER_LINE_LENGTH)`` will return it in + # chunks of MAX_HEADER_LINE_LENGTH bytes until MAX_HEADER_LINES + # is exceeded. + oversized_line = b"X-Huge: " + b"A" * ( + MAX_HEADER_LINE_LENGTH * (MAX_HEADER_LINES + 2) + ) + context.lsp_transport.send_raw(oversized_line) + + +# --------------------------------------------------------------------------- +# Stream desync recovery steps +# --------------------------------------------------------------------------- + + +@given( + "an oversized message with real body followed by" + " an initialize request with id {req_id:d}" +) +def step_oversized_with_body_then_init(context: Context, req_id: int) -> None: + """Build an oversized message with actual body bytes and append a valid init. + + The declared Content-Length exceeds the limit so the server rejects the + message. The *actual* body is ``MAX_CONTENT_LENGTH`` bytes — matching + the server's capped discard — so that ``_discard_body`` consumes + exactly the available body and the stream stays aligned for the next + message. + """ + declared_length = MAX_CONTENT_LENGTH + 64 + # Actual body matches the discard cap so recovery succeeds. + body = b"X" * MAX_CONTENT_LENGTH + header = f"Content-Length: {declared_length}\r\n\r\n".encode() + raw = header + body + # Append the valid initialize request after the oversized body. + init_msg: dict[str, Any] = { + "jsonrpc": "2.0", + "id": req_id, + "method": "initialize", + "params": {"capabilities": {}}, + } + init_payload = json.dumps(init_msg).encode("utf-8") + init_framed = f"Content-Length: {len(init_payload)}\r\n\r\n".encode() + init_payload + context.lsp_transport.send_raw(raw + init_framed) + + +@given( + "a message with non-integer content length followed by" + " an initialize request with id {req_id:d}" +) +def step_invalid_cl_then_init(context: Context, req_id: int) -> None: + """Build a message with invalid CL header followed by valid init.""" + # Invalid CL header + additional header + blank line (body-less) + bad_raw = b"Content-Length: abc\r\nX-Extra: foo\r\n\r\n" + init_msg: dict[str, Any] = { + "jsonrpc": "2.0", + "id": req_id, + "method": "initialize", + "params": {"capabilities": {}}, + } + init_payload = json.dumps(init_msg).encode("utf-8") + init_framed = f"Content-Length: {len(init_payload)}\r\n\r\n".encode() + init_payload + context.lsp_transport.send_raw(bad_raw + init_framed) + + +@given( + "a message with too many header lines followed by" + " an initialize request with id {req_id:d}" +) +def step_too_many_headers_then_init(context: Context, req_id: int) -> None: + """Build a message with excess headers + blank terminator, then valid init.""" + lines = [f"X-Spam-{i}: value\r\n" for i in range(MAX_HEADER_LINES + 1)] + # Append the blank line terminator so _drain_headers can reach it. + lines.append("\r\n") + bad_raw = "".join(lines).encode() + init_msg: dict[str, Any] = { + "jsonrpc": "2.0", + "id": req_id, + "method": "initialize", + "params": {"capabilities": {}}, + } + init_payload = json.dumps(init_msg).encode("utf-8") + init_framed = f"Content-Length: {len(init_payload)}\r\n\r\n".encode() + init_payload + context.lsp_transport.send_raw(bad_raw + init_framed) + + +# --------------------------------------------------------------------------- +# CLI serve command steps +# --------------------------------------------------------------------------- + + +@when('I run lsp CLI serve with log level "{level}"') +def step_run_lsp_serve_bad_level(context: Context, level: str) -> None: + result = _cli_runner.invoke(lsp_app, ["serve", "--log-level", level]) + context.lsp_cli_result = result + + +@when("I run lsp CLI serve with piped empty stdin") +def step_run_lsp_serve_empty_stdin(context: Context) -> None: + result = _cli_runner.invoke(lsp_app, ["serve"], input="") + context.lsp_cli_result = result + + +@when('I run lsp CLI serve with log level "{level}" and piped empty stdin') +def step_run_lsp_serve_level_empty_stdin(context: Context, level: str) -> None: + result = _cli_runner.invoke(lsp_app, ["serve", "--log-level", level], input="") + context.lsp_cli_result = result + + +@then("the lsp serve CLI should have failed") +def step_lsp_serve_failed(context: Context) -> None: + result = context.lsp_cli_result + assert result is not None, "No CLI result captured" + assert result.exit_code != 0, ( + f"Expected non-zero exit code, got {result.exit_code}. Output:\n{result.output}" + ) + + +@then("the lsp serve CLI should have exited") +def step_lsp_serve_exited(context: Context) -> None: + result = context.lsp_cli_result + assert result is not None, "No CLI result captured" + # The server exits with code 0 (clean shutdown) or 1 (no shutdown sent). + assert result.exit_code in (0, 1), ( + f"Unexpected exit code {result.exit_code}. Output:\n{result.output}" + ) + + +@then("the lsp serve CLI should have exited with code {code:d}") +def step_lsp_serve_exited_with_code(context: Context, code: int) -> None: + result = context.lsp_cli_result + assert result is not None, "No CLI result captured" + assert result.exit_code == code, ( + f"Expected exit code {code}, got {result.exit_code}. Output:\n{result.output}" + ) + + +@then('the lsp serve CLI output should contain "{text}"') +def step_lsp_serve_output_contains(context: Context, text: str) -> None: + # NOTE: The serve command writes its startup banner to stderr via + # ``_serve_console = Console(stderr=True)``. This assertion checks + # ``result.output`` (stdout) which works because ``CliRunner()`` + # defaults to ``mix_stderr=True``, merging stderr into stdout. + result = context.lsp_cli_result + assert result is not None, "No CLI result captured" + assert text in result.output, f"Expected '{text}' in output, got:\n{result.output}" + + +# --------------------------------------------------------------------------- +# String request id steps (JSON-RPC 2.0 allows string ids) +# --------------------------------------------------------------------------- + + +@given('an initialize request with string id "{req_id}"') +def step_initialize_string_id(context: Context, req_id: str) -> None: + context.lsp_transport.send_request( + "initialize", + {"capabilities": {}}, + request_id=req_id, + ) + + +@then('the response for string id "{req_id}" should have result') +def step_response_string_id_has_result(context: Context, req_id: str) -> None: + resp = _find_response_any(context, req_id) + assert "result" in resp, f"Response {resp} missing 'result'" + assert resp["result"] is not None, f"Response result is None: {resp}" + + +@then( + 'the result for string id "{req_id}" should contain serverInfo with name "{name}"' +) +def step_result_string_id_server_name( + context: Context, + req_id: str, + name: str, +) -> None: + resp = _find_response_any(context, req_id) + result = resp.get("result", {}) + server_info = result.get("serverInfo", {}) + assert server_info.get("name") == name, ( + f"Expected serverInfo.name={name}, got {server_info}" + ) diff --git a/robot/helper_lsp_stub.py b/robot/helper_lsp_stub.py new file mode 100644 index 000000000..c7f98035f --- /dev/null +++ b/robot/helper_lsp_stub.py @@ -0,0 +1,239 @@ +"""Helper script for Robot Framework LSP stub smoke tests. + +Usage: + python helper_lsp_stub.py + +Commands: + initialize Send initialize request and verify response + shutdown Full shutdown handshake + unsupported Test unsupported method error + startup-pid Verify startup PID reporting + full-lifecycle Complete initialize/shutdown/exit lifecycle +""" + +from __future__ import annotations + +import io +import json +import sys +from pathlib import Path + +# Ensure src is on the path. Robot helpers are invoked as standalone +# scripts via ``Run Process`` so the project package is not always +# importable. This sys.path manipulation is required for that use +# case. If the project is installed in editable mode this insert is +# harmless (the installed entry-point takes precedence). +src_dir = str(Path(__file__).resolve().parents[1] / "src") +if src_dir not in sys.path: + sys.path.insert(0, src_dir) + +from cleveragents.lsp.server import LspServer # noqa: E402 + +# Import the shared response-parsing utility from the test mocks +# package so that parsing logic is defined in a single place. +# Same sys.path rationale as above — needed for standalone execution. +features_dir = str(Path(__file__).resolve().parents[1]) +if features_dir not in sys.path: + sys.path.insert(0, features_dir) + +from features.mocks.lsp_transport_mock import parse_lsp_responses # noqa: E402 + + +def _encode_message(msg: dict) -> bytes: + """Encode a JSON-RPC message with Content-Length framing.""" + payload = json.dumps(msg).encode("utf-8") + header = f"Content-Length: {len(payload)}\r\n\r\n".encode() + return header + payload + + +def _run_server(messages: list[dict]) -> tuple[int, list[dict]]: + """Run LSP server with the given messages and return (exit_code, responses).""" + raw = b"".join(_encode_message(msg) for msg in messages) + input_stream = io.BytesIO(raw) + output_stream = io.BytesIO() + server = LspServer(input_stream=input_stream, output_stream=output_stream) + exit_code = server.run() + output_stream.seek(0) + responses = parse_lsp_responses(output_stream.read()) + return exit_code, responses + + +def cmd_initialize() -> None: + """Test initialize handshake.""" + _, responses = _run_server( + [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"capabilities": {}}, + }, + {"jsonrpc": "2.0", "method": "exit"}, + ] + ) + assert len(responses) >= 1, f"Expected at least 1 response, got {len(responses)}" + result = responses[0].get("result", {}) + server_info = result.get("serverInfo", {}) + assert server_info.get("name") == "cleveragents-lsp-stub", ( + f"Unexpected serverInfo: {server_info}" + ) + assert "capabilities" in result, "Missing capabilities" + print("lsp-initialize-ok") + + +def cmd_shutdown() -> None: + """Test shutdown handshake.""" + exit_code, responses = _run_server( + [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"capabilities": {}}, + }, + {"jsonrpc": "2.0", "id": 2, "method": "shutdown"}, + {"jsonrpc": "2.0", "method": "exit"}, + ] + ) + assert exit_code == 0, f"Expected exit code 0, got {exit_code}" + shutdown_resp = None + for r in responses: + if r.get("id") == 2: + shutdown_resp = r + break + assert shutdown_resp is not None, "No shutdown response" + assert shutdown_resp.get("result") is None, "Shutdown result should be null" + print("lsp-shutdown-ok") + + +def cmd_unsupported() -> None: + """Test unsupported method error after initialize.""" + _, responses = _run_server( + [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"capabilities": {}}, + }, + { + "jsonrpc": "2.0", + "id": 2, + "method": "textDocument/completion", + "params": {}, + }, + {"jsonrpc": "2.0", "method": "exit"}, + ] + ) + assert len(responses) >= 2, f"Expected at least 2 responses, got {len(responses)}" + # Find the completion response (id=2) + completion_resp = None + for r in responses: + if r.get("id") == 2: + completion_resp = r + break + assert completion_resp is not None, "No response for textDocument/completion" + error = completion_resp.get("error", {}) + assert error.get("code") == -32601, f"Expected -32601, got {error.get('code')}" + assert "Not implemented" in error.get("message", ""), ( + f"Unexpected error message: {error.get('message')}" + ) + print("lsp-unsupported-ok") + + +def cmd_startup_pid() -> None: + """Test that server starts and processes messages (PID reported via logging). + + Validates PID plausibility (> 0) by capturing the structlog + ``lsp.server.starting`` event emitted during ``run()``. + """ + import structlog + + exit_code: int + responses: list[dict] + messages = [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"capabilities": {}}, + }, + {"jsonrpc": "2.0", "method": "exit"}, + ] + raw = b"".join(_encode_message(msg) for msg in messages) + input_stream = io.BytesIO(raw) + output_stream = io.BytesIO() + server = LspServer(input_stream=input_stream, output_stream=output_stream) + with structlog.testing.capture_logs() as captured: + exit_code = server.run() + output_stream.seek(0) + responses = parse_lsp_responses(output_stream.read()) + + assert len(responses) >= 1, "Server should produce responses" + # exit_code is 1 because no shutdown was sent + assert exit_code == 1, f"Expected exit code 1 (no shutdown), got {exit_code}" + + # Validate PID plausibility: the server must log its PID as a + # positive integer in the ``lsp.server.starting`` event. + pid_entries = [ + e for e in captured if e.get("event") == "lsp.server.starting" and "pid" in e + ] + assert pid_entries, ( + f"No 'lsp.server.starting' log with PID found in captured logs: {captured}" + ) + logged_pid = pid_entries[0]["pid"] + assert isinstance(logged_pid, int), f"PID should be int, got {type(logged_pid)}" + assert logged_pid > 0, f"PID should be > 0, got {logged_pid}" + print("lsp-startup-pid-ok") + + +def _find_response_by_id(responses: list[dict], req_id: int) -> dict: + """Find a response by its JSON-RPC id.""" + for r in responses: + if r.get("id") == req_id: + return r + raise AssertionError(f"No response with id={req_id} in {responses}") + + +def cmd_full_lifecycle() -> None: + """Test complete lifecycle.""" + exit_code, responses = _run_server( + [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"capabilities": {}}, + }, + {"jsonrpc": "2.0", "id": 2, "method": "shutdown"}, + {"jsonrpc": "2.0", "method": "exit"}, + ] + ) + assert exit_code == 0, f"Expected exit code 0, got {exit_code}" + assert len(responses) == 2, f"Expected 2 responses, got {len(responses)}" + + # Verify initialize response (by id, not index) + init_resp = _find_response_by_id(responses, 1) + assert "capabilities" in init_resp.get("result", {}) + + # Verify shutdown response (by id, not index) + shutdown_resp = _find_response_by_id(responses, 2) + assert shutdown_resp.get("result") is None + + print("lsp-full-lifecycle-ok") + + +COMMANDS = { + "initialize": cmd_initialize, + "shutdown": cmd_shutdown, + "unsupported": cmd_unsupported, + "startup-pid": cmd_startup_pid, + "full-lifecycle": cmd_full_lifecycle, +} + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(COMMANDS)}>", file=sys.stderr) + sys.exit(1) + COMMANDS[sys.argv[1]]() diff --git a/robot/lsp_stub_smoke.robot b/robot/lsp_stub_smoke.robot new file mode 100644 index 000000000..17145e256 --- /dev/null +++ b/robot/lsp_stub_smoke.robot @@ -0,0 +1,49 @@ +*** Settings *** +Documentation Smoke tests for LSP server stub startup and protocol +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_lsp_stub.py + +*** Test Cases *** +LSP Server Initialize Handshake + [Documentation] Send initialize request and verify response + ${result}= Run Process ${PYTHON} ${HELPER} initialize cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} lsp-initialize-ok + +LSP Server Shutdown Handshake + [Documentation] Send initialize, shutdown, exit and verify clean exit + ${result}= Run Process ${PYTHON} ${HELPER} shutdown cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} lsp-shutdown-ok + +LSP Server Unsupported Method + [Documentation] Send unsupported method and verify MethodNotFound error + ${result}= Run Process ${PYTHON} ${HELPER} unsupported cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} lsp-unsupported-ok + +LSP Server Startup Reports PID + [Documentation] Verify server startup reports PID + ${result}= Run Process ${PYTHON} ${HELPER} startup-pid cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} lsp-startup-pid-ok + +LSP Server Full Lifecycle + [Documentation] Complete lifecycle: initialize, shutdown, exit + ${result}= Run Process ${PYTHON} ${HELPER} full-lifecycle cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} lsp-full-lifecycle-ok diff --git a/src/cleveragents/cli/commands/lsp.py b/src/cleveragents/cli/commands/lsp.py index 811d893a9..d77f3fb75 100644 --- a/src/cleveragents/cli/commands/lsp.py +++ b/src/cleveragents/cli/commands/lsp.py @@ -45,9 +45,12 @@ task M2.7.lsp-stubs. from __future__ import annotations +import logging +import os from pathlib import Path from typing import Annotated, Any +import structlog import typer import yaml from pydantic import ValidationError as PydanticValidationError @@ -112,7 +115,6 @@ def add( "--config", "-c", help="Path to the LSP server YAML configuration file", - exists=False, ), ], update: Annotated[ @@ -134,6 +136,11 @@ def add( agents lsp add --config ./lsp/pyright.yaml --update """ try: + # Typer's ``exists`` parameter defaults to ``False`` (no + # automatic file-existence validation). The manual check + # below is deliberate: it provides a user-friendly Rich + # error message instead of Typer's default traceback-style + # ``FileNotFoundError``. if not config.exists(): console.print(f"[red]Config file error:[/red] {config} not found") raise typer.Abort() @@ -327,3 +334,89 @@ def show( console.print(Panel("\n".join(env_lines), title="Environment", expand=False)) console.print("[green]OK[/green] LSP server loaded") + + +_VALID_LOG_LEVELS = ("debug", "info", "warning", "error") + +# Dedicated stderr console for the serve command so that the +# startup banner does not corrupt the JSON-RPC transport on stdout. +_serve_console = Console(stderr=True) + + +@app.command("serve") +def serve( + log_level: Annotated[ + str, + typer.Option( + "--log-level", + help="Logging level: debug, info, warning, error (default: info)", + ), + ] = "info", +) -> None: + """Launch the stub LSP server over stdin/stdout. + + The server speaks JSON-RPC with Content-Length header framing + (the standard LSP base protocol transport). It supports the + ``initialize``, ``shutdown``, and ``exit`` lifecycle methods. + All other LSP methods return a MethodNotFound error. + + Examples: + agents lsp serve + agents lsp serve --log-level debug + """ + normalised = log_level.lower() + if normalised not in _VALID_LOG_LEVELS: + _serve_console.print( + f"[red]ERROR[/red] Invalid log level '{log_level}'. " + f"Valid levels: {', '.join(_VALID_LOG_LEVELS)}" + ) + raise typer.Exit(code=1) + + level = getattr(logging, normalised.upper(), logging.INFO) + previous_config: dict[str, Any] = dict(structlog.get_config()) + exit_code = 1 + try: + structlog.configure( + wrapper_class=structlog.make_filtering_bound_logger(level), + ) + log = structlog.get_logger("cleveragents.lsp.cli") + + pid = os.getpid() + log.info("lsp.serve.startup", pid=pid, log_level=normalised) + _serve_console.print( + "[bold cyan]CleverAgents LSP Server[/bold cyan]" + f" (stub) starting — PID {pid}" + ) + _serve_console.print( + f" Log level: {normalised}\n" + " Transport: stdin/stdout (JSON-RPC, Content-Length framing)\n" + " Supported methods: initialize, shutdown, exit\n" + " All other methods → MethodNotFound (-32601)" + ) + + # Lazy import: LspServer transitively imports AcpLocalFacade + # which pulls the full ACP dependency chain (~200ms cold). + # Non-serve subcommands (add, remove, list, show) do not + # need LspServer, so deferring the import avoids that cost + # for the common case. + from cleveragents.lsp.server import LspServer + + server = LspServer() + exit_code = server.run() + log.info("lsp.serve.stopped", pid=pid, exit_code=exit_code) + finally: + # Restore the previous structlog configuration. Note: + # ``structlog.configure()`` unconditionally sets + # ``_CONFIG.is_configured = True`` internally, and + # ``get_config()`` does not capture this flag. If structlog + # was using defaults (not yet configured) before ``serve()`` + # was called, subsequent ``configure_once()`` invocations + # will silently refuse to apply their configuration because + # they see ``is_configured == True``. There are currently + # zero ``configure_once()`` calls in this codebase so the + # impact is latent. Accept this limitation rather than + # calling ``reset_defaults()`` which would discard any + # processor chain the caller set up. + structlog.configure(**previous_config) + + raise typer.Exit(code=exit_code) diff --git a/src/cleveragents/lsp/__init__.py b/src/cleveragents/lsp/__init__.py index 1ed862cd3..b706ebb76 100644 --- a/src/cleveragents/lsp/__init__.py +++ b/src/cleveragents/lsp/__init__.py @@ -1,19 +1,23 @@ """LSP (Language Server Protocol) integration package. -Provides the registry, runtime stub, tool adapter, and domain models -for managing language servers within CleverAgents. +Provides the registry, runtime stub, tool adapter, server stub, +and domain models for managing language servers within CleverAgents. In **local mode** the :class:`LspRuntime` and :class:`LspToolAdapter` are stubs: the runtime raises :class:`LspNotAvailableError` for every operation and the tool adapter generates tool specs whose handlers -also raise immediately. When server mode is implemented the concrete -classes will replace these stubs while preserving the public API. +also raise immediately. The :class:`LspServer` provides a minimal +JSON-RPC server stub over stdin/stdout. + +When server mode is implemented the concrete classes will replace +these stubs while preserving the public API. Usage:: from cleveragents.lsp import ( LspRegistry, LspRuntime, + LspServer, LspToolAdapter, LspServerConfig, LspCapability, @@ -32,6 +36,7 @@ from cleveragents.lsp.models import ( ) from cleveragents.lsp.registry import LspRegistry from cleveragents.lsp.runtime import LspRuntime +from cleveragents.lsp.server import LspServer from cleveragents.lsp.tool_adapter import LspToolAdapter __all__ = [ @@ -41,6 +46,7 @@ __all__ = [ "LspNotAvailableError", "LspRegistry", "LspRuntime", + "LspServer", "LspServerConfig", "LspServerNotFoundError", "LspToolAdapter", diff --git a/src/cleveragents/lsp/server.py b/src/cleveragents/lsp/server.py new file mode 100644 index 000000000..2c93564d8 --- /dev/null +++ b/src/cleveragents/lsp/server.py @@ -0,0 +1,554 @@ +"""Minimal LSP server stub with JSON-RPC over stdin/stdout transport. + +Implements the Language Server Protocol base protocol with +Content-Length header framing. Supports the ``initialize``, +``shutdown``, and ``exit`` lifecycle methods. All other LSP +methods return a JSON-RPC ``MethodNotFound`` (-32601) error. + +An :class:`AcpLocalFacade` instance is stored so that the +dispatch path can be wired through ACP when server mode lands. +In the current stub the facade is a placeholder — all methods +are handled by hardcoded stub handlers. + +Usage:: + + from cleveragents.lsp.server import LspServer + server = LspServer() + server.run() # blocks reading stdin +""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any, BinaryIO + +import structlog + +from cleveragents.acp.facade import AcpLocalFacade + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + +# JSON-RPC error codes (LSP specification) +_PARSE_ERROR = -32700 +_INVALID_REQUEST = -32600 +_METHOD_NOT_FOUND = -32601 +SERVER_NOT_INITIALIZED = -32002 + +# LSP server metadata +_SERVER_NAME = "cleveragents-lsp-stub" +_SERVER_VERSION = "0.1.0" + +# Maximum Content-Length accepted (10 MB). Rejects messages that +# exceed this limit to prevent memory exhaustion from malicious or +# malformed headers. +MAX_CONTENT_LENGTH = 10 * 1024 * 1024 + +# Maximum number of header lines to read before giving up. Prevents +# denial-of-service from an adversarial client sending infinite +# non-empty header lines without a blank terminator. +MAX_HEADER_LINES = 32 + +# Maximum bytes ``readline()`` will consume for a single header line. +# Without this cap a malicious client sending a multi-gigabyte line +# without a newline could cause unbounded memory allocation. The +# MAX_HEADER_LINES guard fires *after* a complete line has been read, +# so it alone does not limit per-line memory. With this cap the +# worst-case header memory is MAX_HEADER_LINES * MAX_HEADER_LINE_LENGTH +# (32 * 8192 = 256 KB). +MAX_HEADER_LINE_LENGTH = 8192 + + +class _SkipMessage: + """Sentinel returned by ``_read_message`` for recoverable transport errors. + + Distinguishes recoverable single-message failures (bad framing, + oversized payload, malformed JSON) from a true EOF condition. The + run loop continues when ``_SKIP`` is returned instead of + terminating the server. + """ + + __slots__ = () + + +_SKIP = _SkipMessage() + +# Stubbed capability set reported during initialize. All +# capabilities from the specification's LSP Capability Exposure +# section are represented (disabled or null) so that clients see +# the full surface even while the server is a stub. +_STUBBED_CAPABILITIES: dict[str, Any] = { + "textDocumentSync": 0, + "completionProvider": None, + "hoverProvider": False, + "definitionProvider": False, + "referencesProvider": False, + "documentFormattingProvider": False, + "renameProvider": False, + "codeActionProvider": False, + "diagnosticProvider": None, + "signatureHelpProvider": None, + "documentSymbolProvider": False, + "workspaceSymbolProvider": False, +} + + +class LspServer: + """Minimal LSP server stub over JSON-RPC stdin/stdout transport. + + The server reads Content-Length framed JSON-RPC messages from + *input_stream* and writes responses to *output_stream*. LSP + requests are routed through an :class:`AcpLocalFacade` instance + in local mode. + + Attributes: + _facade: ACP local-mode facade for request routing. + _input: Binary stream to read from (default: ``sys.stdin.buffer``). + _output: Binary stream to write to (default: ``sys.stdout.buffer``). + _running: Whether the event loop is active. + _initialized: Whether ``initialize`` has been received. + _shutdown_requested: Whether ``shutdown`` has been received. + """ + + def __init__( + self, + input_stream: BinaryIO | None = None, + output_stream: BinaryIO | None = None, + facade: AcpLocalFacade | None = None, + ) -> None: + if input_stream is not None and not hasattr(input_stream, "read"): + raise TypeError("input_stream must support read()") + if output_stream is not None and not hasattr(output_stream, "write"): + raise TypeError("output_stream must support write()") + self._input: BinaryIO = input_stream or sys.stdin.buffer + self._output: BinaryIO = output_stream or sys.stdout.buffer + self._facade: AcpLocalFacade | None = facade + self._running: bool = False + self._initialized: bool = False + self._shutdown_requested: bool = False + + @property + def facade(self) -> AcpLocalFacade: + """Return the ACP facade, creating it lazily on first access. + + The facade is stored for future server-mode wiring but is + not called in the current stub. Lazy creation avoids the + overhead of ``AcpLocalFacade()`` construction when the + facade is never needed (e.g., benchmarks). + """ + if self._facade is None: + self._facade = AcpLocalFacade() + return self._facade + + @property + def is_running(self) -> bool: + """Whether the server event loop is currently active.""" + return self._running + + # ------------------------------------------------------------------ + # Transport layer + # ------------------------------------------------------------------ + + def _drain_headers(self) -> None: + """Consume remaining header lines up to the blank terminator. + + Called after an early-exit error during header parsing + (invalid Content-Length, too many header lines) so that the + stream is left positioned at the start of the next message. + Without this drain, unconsumed header bytes would be + misinterpreted as headers of subsequent messages, causing a + cascading transport desync. + + A secondary line-count cap (``MAX_HEADER_LINES``) prevents + a malicious client from keeping the server in this drain + loop indefinitely by streaming infinite non-blank lines + after the primary guard fires. + """ + for _ in range(MAX_HEADER_LINES): + raw_line = self._input.readline(MAX_HEADER_LINE_LENGTH) + if not raw_line: + return + line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") + if line == "": + return + + def _discard_body(self, content_length: int) -> None: + """Read and discard body bytes from the input stream. + + Called when a message is rejected after headers are fully + parsed (oversized payload) so that the body bytes do not + corrupt the framing of subsequent messages. + + The number of bytes actually discarded is capped at + :data:`MAX_CONTENT_LENGTH` to prevent a malicious client + from blocking the server with an absurdly large declared + ``Content-Length`` (e.g. 10 TB). Beyond that cap the + stream framing is unrecoverable anyway. + """ + remaining = min(content_length, MAX_CONTENT_LENGTH) + while remaining > 0: + chunk = self._input.read(min(remaining, 65536)) + if not chunk: + break + remaining -= len(chunk) + + def _read_message(self) -> Any | None: + """Read a single JSON-RPC message using Content-Length framing. + + Returns the parsed JSON value (typically a dict, but may be + any JSON-decoded type such as a list or scalar), ``None`` on + true EOF, or the :data:`_SKIP` sentinel for recoverable + transport errors (bad framing, oversized payload, malformed + JSON). The sentinel allows the run loop to drop the current + message and continue reading the next one rather than + terminating the server. + + On recoverable errors the method drains unconsumed header + and/or body bytes so that the stream is correctly positioned + for the next message, preventing transport desync. + """ + content_length: int | None = None + header_lines_read = 0 + + while True: + raw_line = self._input.readline(MAX_HEADER_LINE_LENGTH) + if not raw_line: + return None + header_lines_read += 1 + if header_lines_read > MAX_HEADER_LINES: + logger.warning( + "lsp.transport.too_many_header_lines", + count=header_lines_read, + max_allowed=MAX_HEADER_LINES, + ) + self._drain_headers() + return _SKIP + line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") + if line == "": + break + if line.lower().startswith("content-length:"): + try: + content_length = int(line.split(":", 1)[1].strip()) + except (ValueError, IndexError): + logger.warning("lsp.transport.invalid_content_length", raw=line) + self._drain_headers() + return _SKIP + + if content_length is None: + logger.warning("lsp.transport.missing_content_length") + return _SKIP + + if content_length < 0: + logger.warning( + "lsp.transport.negative_content_length", + content_length=content_length, + ) + return _SKIP + + if content_length > MAX_CONTENT_LENGTH: + logger.warning( + "lsp.transport.content_length_exceeded", + content_length=content_length, + max_allowed=MAX_CONTENT_LENGTH, + ) + self._discard_body(content_length) + return _SKIP + + # SEC: ``read(content_length)`` blocks until exactly + # ``content_length`` bytes arrive or EOF. A malicious client + # that sends a valid Content-Length but stalls mid-body will + # block the server indefinitely. MAX_CONTENT_LENGTH limits + # memory but not time. For this stub the caller (IDE / + # development harness) is trusted; when evolving to + # production, wrap the read in ``select()`` / ``asyncio`` + # with a per-message timeout. + data = self._input.read(content_length) + if len(data) < content_length: + logger.warning( + "lsp.transport.incomplete_read", + expected=content_length, + got=len(data), + ) + return _SKIP + + try: + parsed: Any = json.loads(data) + except json.JSONDecodeError as exc: + logger.warning("lsp.transport.json_parse_error", error=str(exc)) + self._write_message( + self._make_error_response(None, _PARSE_ERROR, f"Parse error: {exc}") + ) + return _SKIP + except RecursionError: + logger.warning( + "lsp.transport.json_recursion_error", + size=len(data), + ) + self._write_message( + self._make_error_response( + None, _PARSE_ERROR, "JSON nesting depth exceeded" + ) + ) + return _SKIP + + return parsed + + def _write_message(self, body: dict[str, Any]) -> bool: + """Write a JSON-RPC response with Content-Length framing. + + Returns ``True`` on success, ``False`` if the output stream + is broken (e.g. closed pipe). The caller should stop the + event loop when ``False`` is returned. + """ + payload = json.dumps(body, separators=(",", ":")).encode("utf-8") + header = f"Content-Length: {len(payload)}\r\n\r\n".encode() + try: + self._output.write(header + payload) + self._output.flush() + except OSError: + logger.warning("lsp.transport.write_error") + return False + return True + + # ------------------------------------------------------------------ + # JSON-RPC helpers + # ------------------------------------------------------------------ + + def _make_response( + self, + request_id: int | str | None, + result: Any, + ) -> dict[str, Any]: + """Build a JSON-RPC success response.""" + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + def _make_error_response( + self, + request_id: int | str | None, + code: int, + message: str, + data: Any = None, + ) -> dict[str, Any]: + """Build a JSON-RPC error response.""" + error: dict[str, Any] = {"code": code, "message": message} + if data is not None: + error["data"] = data + return {"jsonrpc": "2.0", "id": request_id, "error": error} + + # ------------------------------------------------------------------ + # Method dispatch + # ------------------------------------------------------------------ + + def _handle_message(self, msg: Any) -> dict[str, Any] | None: + """Dispatch a parsed JSON-RPC message to the appropriate handler. + + *msg* may be any JSON-decoded value (``json.loads`` can + return lists, strings, etc.). Non-dict values are rejected + with ``InvalidRequest``. + + Returns a response dict, or ``None`` for notifications (no id). + """ + if not isinstance(msg, dict): + return self._make_error_response( + None, _INVALID_REQUEST, "Invalid JSON-RPC message" + ) + + jsonrpc = msg.get("jsonrpc") + if jsonrpc != "2.0": + return self._make_error_response( + msg.get("id"), + _INVALID_REQUEST, + "Invalid or missing jsonrpc version", + ) + + method = msg.get("method") + if not isinstance(method, str): + return self._make_error_response( + msg.get("id"), + _INVALID_REQUEST, + "Missing or invalid method field", + ) + + request_id = msg.get("id") + params = msg.get("params", {}) + if not isinstance(params, dict): + params = {} + + result = self._dispatch_stub(method, params, request_id) + return result + + def _dispatch_stub( + self, + method: str, + params: dict[str, Any], + request_id: int | str | None, + ) -> dict[str, Any] | None: + """Dispatch LSP methods to hardcoded stub handlers. + + The :attr:`_facade` is stored for future server-mode wiring + but is **not called** in the current stub implementation. + When server mode lands this method should route through the + facade instead of the hardcoded ``if`` chain below. + + .. todo:: Wire dispatch through ``self._facade`` when server + mode is implemented (see M7+ roadmap). + """ + logger.debug("lsp.dispatch", method=method, request_id=request_id, mode="stub") + + # After shutdown only ``exit`` is accepted (LSP spec §3.16). + if self._shutdown_requested and method != "exit": + if request_id is None: + return None + return self._make_error_response( + request_id, + _INVALID_REQUEST, + "Server is shutting down", + ) + + if method == "initialize": + return self._handle_initialize(request_id, params) + if method == "exit": + self._handle_exit() + return None + + # Before ``initialize`` is received, requests must receive + # ``ServerNotInitialized`` (-32002) per LSP specification. + # Only ``initialize`` and ``exit`` are exempt from this + # guard — ``shutdown`` is a normal request and must be + # rejected when the server has not been initialized. + if not self._initialized and request_id is not None: + return self._make_error_response( + request_id, + SERVER_NOT_INITIALIZED, + f"Server not initialized: {method}", + ) + + if method == "shutdown": + return self._handle_shutdown(request_id) + + # Notification (no id) — silently ignore + if request_id is None: + logger.info("lsp.notification.ignored", method=method) + return None + + return self._make_error_response( + request_id, + _METHOD_NOT_FOUND, + f"Not implemented: {method}", + ) + + # ------------------------------------------------------------------ + # LSP lifecycle handlers + # ------------------------------------------------------------------ + + def _handle_initialize( + self, + request_id: int | str | None, + params: dict[str, Any], + ) -> dict[str, Any]: + """Handle the ``initialize`` request. + + Returns a stubbed ``InitializeResult`` with server info and + capabilities. A second ``initialize`` request is rejected + with ``InvalidRequest`` per the LSP specification. + """ + if self._initialized: + logger.warning("lsp.initialize.duplicate", pid=os.getpid()) + return self._make_error_response( + request_id, + _INVALID_REQUEST, + "Server already initialized", + ) + logger.info( + "lsp.initialize", + client_info=params.get("clientInfo"), + pid=os.getpid(), + ) + # NOTE: ``_initialized`` is set upon receiving the ``initialize`` + # *request*, not the subsequent ``initialized`` *notification*. + # Per a strict LSP reading the server should not fully activate + # until the ``initialized`` notification arrives, but for a stub + # this is acceptable. Track this deviation when evolving into + # the real server (see M7+ roadmap). + self._initialized = True + result: dict[str, Any] = { + "capabilities": dict(_STUBBED_CAPABILITIES), + "serverInfo": { + "name": _SERVER_NAME, + "version": _SERVER_VERSION, + }, + } + return self._make_response(request_id, result) + + def _handle_shutdown( + self, + request_id: int | str | None, + ) -> dict[str, Any]: + """Handle the ``shutdown`` request. + + Acknowledges and prepares for exit. + """ + logger.info("lsp.shutdown", pid=os.getpid()) + self._shutdown_requested = True + return self._make_response(request_id, None) + + def _handle_exit(self) -> None: + """Handle the ``exit`` notification. + + Terminates the server process. The final exit code is + computed by :meth:`run` after the event loop ends (single + source of truth based on ``_shutdown_requested``). + """ + logger.info( + "lsp.exit", + code=0 if self._shutdown_requested else 1, + pid=os.getpid(), + ) + self._running = False + + # ------------------------------------------------------------------ + # Main event loop + # ------------------------------------------------------------------ + + def run(self) -> int: + """Start the LSP server event loop. + + Reads JSON-RPC messages from stdin and writes responses to + stdout until an ``exit`` notification is received or stdin + is closed. + + Returns: + Exit code: 0 if shutdown was requested before exit, 1 + otherwise. + """ + pid = os.getpid() + logger.info("lsp.server.starting", pid=pid, server=_SERVER_NAME) + self._running = True + + while self._running: + msg = self._read_message() + if msg is None: + logger.info("lsp.server.eof", pid=pid) + self._running = False + break + if isinstance(msg, _SkipMessage): + continue + + response = self._handle_message(msg) + if response is not None and not self._write_message(response): + self._running = False + break + + exit_code = 0 if self._shutdown_requested else 1 + logger.info("lsp.server.stopped", pid=pid, exit_code=exit_code) + return exit_code + + +__all__ = [ + "MAX_CONTENT_LENGTH", + "MAX_HEADER_LINES", + "MAX_HEADER_LINE_LENGTH", + "SERVER_NOT_INITIALIZED", + "LspServer", +] diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 12182182b..0eab344aa 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -816,3 +816,16 @@ ScoredFragment # noqa: B018, F821 score_detailed # noqa: B018, F821 depth_fallback_steps # noqa: B018, F821 min_fragment_tokens # noqa: B018, F821 + +# LSP Server Stub — public API (issue #203) +LspServer # noqa: B018, F821 +MAX_CONTENT_LENGTH # noqa: B018, F821 +MAX_HEADER_LINES # noqa: B018, F821 +SERVER_NOT_INITIALIZED # noqa: B018, F821 +MockLspTransport # noqa: B018, F821 + +# cleveragents.cli.commands.lsp — Typer CLI entrypoint for ``agents lsp serve`` +serve # noqa: B018, F821 + +# cleveragents.lsp.server.LspServer.facade — lazy ACP facade property +facade # noqa: B018, F821