feat(lsp): add LSP server stub
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 4m0s
CI / docker (pull_request) Successful in 38s
CI / integration_tests (pull_request) Successful in 4m48s
CI / coverage (pull_request) Successful in 4m31s
CI / lint (push) Successful in 12s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 15s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 35s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m27s
CI / integration_tests (push) Successful in 3m3s
CI / docker (push) Successful in 38s
CI / coverage (push) Successful in 4m25s
CI / benchmark-publish (push) Successful in 16m28s
CI / benchmark-regression (pull_request) Successful in 28m52s
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 4m0s
CI / docker (pull_request) Successful in 38s
CI / integration_tests (pull_request) Successful in 4m48s
CI / coverage (pull_request) Successful in 4m31s
CI / lint (push) Successful in 12s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 15s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 35s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m27s
CI / integration_tests (push) Successful in 3m3s
CI / docker (push) Successful in 38s
CI / coverage (push) Successful in 4m25s
CI / benchmark-publish (push) Successful in 16m28s
CI / benchmark-regression (pull_request) Successful in 28m52s
Added minimal LSP server entrypoint supporting initialize/shutdown/exit handshake over JSON-RPC stdin/stdout transport with Content-Length framing. Unsupported methods return MethodNotFound error with descriptive message. Wired LSP requests through ACP facade in local mode. Added agents lsp serve CLI command with --log-level flag, PID output, and startup banner. Created reference documentation for the stub server. Includes Behave BDD tests for protocol handshake, Robot smoke test, and ASV startup latency benchmark. ISSUES CLOSED: #203
This commit was merged in pull request #608.
This commit is contained in:
@@ -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``,
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -301,6 +301,7 @@ The following standards are integrated into the architecture:
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> lsp remove [<span style="color: cyan;">--yes</span>|<span style="color: yellow;">-y</span>] <span style="color: #66cc66;"><NAME></span>
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> lsp list [(<span style="color: magenta;"><span style="color: cyan;">--namespace</span>|<span style="color: yellow;">-n</span></span>) <span style="color: #66cc66;"><NS></span>] [<span style="color: cyan;">--language</span> <span style="color: #66cc66;"><LANG></span>]
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> lsp show <span style="color: #66cc66;"><NAME></span>
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> lsp serve [<span style="color: cyan;">--log-level</span> <span style="color: #66cc66;"><LEVEL></span>]
|
||||
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> resource type add (<span style="color: magenta;"><span style="color: cyan;">--config</span>|<span style="color: yellow;">-c</span></span>) <span style="color: #66cc66;"><FILE></span> [<span style="color: cyan;">--update</span>]
|
||||
<span style="color: cyan; font-weight: 600;">agents</span> resource type remove [<span style="color: cyan;">--yes</span>|<span style="color: yellow;">-y</span>] <span style="color: #66cc66;"><NAME></span>
|
||||
@@ -9205,6 +9206,52 @@ Show full details for a registered LSP server, including its configuration, supp
|
||||
- LSP server details loaded
|
||||
```
|
||||
|
||||
##### agents lsp serve
|
||||
|
||||
<div class="highlight"><pre><code><span style="color: cyan; font-weight: 600;">agents</span> lsp serve [<span style="color: cyan;">--log-level</span> <span style="color: #66cc66;"><LEVEL></span>]</code></pre></div>
|
||||
|
||||
**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"
|
||||
|
||||
<div class="highlight"><pre><code>
|
||||
<span style="color: #66cc66; font-weight: 600;">$</span> <span style="color: cyan; font-weight: 600;">agents</span> lsp serve
|
||||
|
||||
<span style="color: cyan; font-weight: 600;">CleverAgents LSP Server</span> (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)
|
||||
</code></pre></div>
|
||||
|
||||
=== "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"
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Helper script for Robot Framework LSP stub smoke tests.
|
||||
|
||||
Usage:
|
||||
python helper_lsp_stub.py <command>
|
||||
|
||||
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]]()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user