Files
xiaoli-cli/tests/test_lsp_client.py
T
shuiyu67andWorkBuddy 9dd418ec89 feat: LSP 集成(连接外部语言服务器,真实诊断/智能感知喂给模型)
- 新增 lsp_client.py:LspClient(subprocess stdio,Content-Length 分帧 JSON-RPC,
  reader 线程,initialize/initialized/didOpen/didChange/hover/definition/references/
  completion + publishDiagnostics 通知捕获)+ LspManager(按扩展名→语言→server 路由、
  connect_all 读 lspServers、get_context 返回已分析文件诊断、cleanup 幂等)
- unified_tool_manager.py:initialize 末尾加载 LSP server,注册 5 个 lsp__* 工具
  (diagnostics/hover/definition/references/completion,带真实 inputSchema、protocol='lsp'、
  instance 存 manager 供 shutdown 清理)
- cli_core.py:_build_system_prompt 末尾注入 LSP 诊断上下文(模型持续看到真实诊断)
- config.json:顶层加 lspServers 段(python/rust 示例,含 _说明;该文件被 gitignore 不入库)
- 新增 tests/test_lsp_client.py(9 项:fake server 全链路+路由+桥接+fc_tools 保真+缺失/失败优雅降级)
- 修 LSP 线缆坑:didOpen/didChange 是通知(无响应),初版误当请求等响应导致超时

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-05 17:49:46 +08:00

293 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
LSP 客户端测试
==============
用 inline 的 fake LSP server(Content-Length 分帧 JSON-RPC)验证:
- LspClient 全链路(initialize / didOpen 触发诊断 / hover / definition / shutdown)
- LspManager 按扩展名路由、多 server
- UnifiedToolManager 桥接:5 个 lsp__* 工具注册、get_mcp_tools 可见、execute 返回诊断
- get_lsp_context 注入
- fc_tools 保真(lsp 工具进入模型视野)
全程不触真实引擎 / 网络。
"""
import os
import sys
import json
import time
import threading
import pytest
import lsp_client
from lsp_client import LspClient, LspManager, path_to_uri, ext_to_language
# ── Fake LSP server(Content-Length 分帧)──
FAKE_SERVER_SRC = r'''
import sys, json, threading, time
def send(obj):
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(data))
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
def read_msg():
header = b""
while b"\r\n\r\n" not in header:
c = sys.stdin.buffer.read(1)
if not c:
return None
header += c
m = __import__("re").search(rb"Content-Length:\s*(\d+)", header)
if not m:
return None
n = int(m.group(1))
body = b""
while len(body) < n:
c = sys.stdin.buffer.read(n - len(body))
if not c:
return None
body += c
return json.loads(body.decode("utf-8"))
def main():
initialized = threading.Event()
while True:
msg = read_msg()
if msg is None:
break
mid = msg.get("id")
method = msg.get("method")
if method == "initialize":
send({"jsonrpc": "2.0", "id": mid, "result": {
"capabilities": {"hoverProvider": True,
"definitionProvider": True,
"referencesProvider": True,
"completionProvider": {}}}})
elif method == "initialized":
initialized.set()
elif method == "textDocument/didOpen":
params = msg.get("params", {})
uri = params.get("textDocument", {}).get("uri", "")
# 立即推送一条诊断
send({"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics",
"params": {"uri": uri, "diagnostics": [
{"range": {"start": {"line": 2, "character": 4},
"end": {"line": 2, "character": 7}},
"severity": 1, "source": "fake-lsp",
"message": "Undefined variable 'foo'"}]}})
elif method == "textDocument/hover":
send({"jsonrpc": "2.0", "id": mid, "result": {
"contents": {"kind": "markdown", "value": "def foo(): ..."}}})
elif method == "textDocument/definition":
send({"jsonrpc": "2.0", "id": mid, "result": {
"uri": "file:///x.py", "range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 3}}}})
elif method == "textDocument/references":
send({"jsonrpc": "2.0", "id": mid, "result": [
{"uri": "file:///x.py", "range": {
"start": {"line": 1, "character": 0},
"end": {"line": 1, "character": 3}}}]})
elif method == "textDocument/completion":
send({"jsonrpc": "2.0", "id": mid, "result": {
"items": [{"label": "bar"}, {"label": "baz"}]}})
elif method == "shutdown":
send({"jsonrpc": "2.0", "id": mid, "result": None})
elif method == "exit":
break
else:
if mid is not None:
send({"jsonrpc": "2.0", "id": mid, "result": {}})
if __name__ == "__main__":
main()
'''
@pytest.fixture
def fake_server(tmp_path):
p = tmp_path / "fake_lsp_server.py"
p.write_text(FAKE_SERVER_SRC)
return str(p)
def test_path_to_uri_windows():
uri = path_to_uri("C:\\Users\\me\\a.py")
assert uri.startswith("file:///")
assert uri.endswith("/C:/Users/me/a.py")
def test_ext_to_language():
assert ext_to_language("x.py") == "python"
assert ext_to_language("x.rs") == "rust"
assert ext_to_language("x.unknown") is None
def test_lsp_client_lifecycle(fake_server, tmp_path):
code_file = tmp_path / "sample.py"
code_file.write_text("print('hi')\nx = 1\nfoo = 2\n")
client = LspClient("python", sys.executable, [fake_server],
language_id="python", root_uri="file:///tmp")
try:
client.start()
init = client.initialize()
assert isinstance(init, dict)
diags = client.diagnostics(str(code_file), wait=3.0)
assert len(diags) == 1
assert "Undefined variable" in diags[0]["message"]
hover = client.hover(str(code_file), 0, 0)
assert hover and "foo" in json.dumps(hover)
definition = client.definition(str(code_file), 0, 0)
assert definition
references = client.references(str(code_file), 0, 0)
assert references
completion = client.completion(str(code_file), 0, 0)
assert completion
finally:
client.shutdown()
def test_lsp_manager_routing(fake_server, tmp_path):
# 用 .py 扩展名映射到 fake server(languageId=python)
servers = {"python": {"command": sys.executable, "args": [fake_server],
"disabled": False}}
mgr = LspManager()
mgr.connect_all(servers, cwd=str(tmp_path), root_uri="file:///tmp")
assert "python" in mgr.get_servers()
code_file = tmp_path / "sample.py"
code_file.write_text("a = 1\nb = 2\nfoo = 3\n")
out = mgr.diagnostics(str(code_file))
assert "Undefined variable" in out
# 诊断被缓存,供上下文注入
ctx = mgr.get_context()
assert "Undefined variable" in ctx
hover_out = mgr.hover(str(code_file), 0, 0)
assert "foo" in hover_out
def test_lsp_manager_unknown_ext_graceful(fake_server, tmp_path):
servers = {"python": {"command": sys.executable, "args": [fake_server]}}
mgr = LspManager()
mgr.connect_all(servers, cwd=str(tmp_path), root_uri="file:///tmp")
# .rs 没有配置 server,应优雅提示而非崩溃
out = mgr.diagnostics(str(tmp_path / "x.rs"))
assert "没有为" in out or "LSP server" in out
def test_lsp_manager_connect_failure_graceful(tmp_path):
# 命令不存在 → 不应抛异常,仅记录
servers = {"python": {"command": "this_command_does_not_exist_xyz"}}
mgr = LspManager()
mgr.connect_all(servers, cwd=str(tmp_path), root_uri="file:///tmp")
assert mgr.get_servers() == []
def test_unified_manager_bridges_lsp(fake_server, tmp_path, monkeypatch):
code_file = tmp_path / "sample.py"
code_file.write_text("a = 1\nb = 2\nfoo = 3\n")
cfg = {
"mcpServers": {},
"lspServers": {"python": {"command": sys.executable, "args": [fake_server],
"disabled": False}},
}
import xcli_core.config as xcfg
monkeypatch.setattr(xcfg, "load_config", lambda: cfg)
try:
import config as root_cfg
monkeypatch.setattr(root_cfg, "load_config", lambda: cfg)
except ImportError:
pass
import unified_tool_manager as utm
mgr = utm.UnifiedToolManager()
mgr.initialize(
plugins_dir=str(tmp_path / "noplugins"),
skills_dir=str(tmp_path / "noskills"),
)
try:
tools = mgr.get_mcp_tools()
names = [t["name"] for t in tools]
for expected in ("lsp__diagnostics", "lsp__hover", "lsp__definition",
"lsp__references", "lsp__completion"):
assert expected in names, f"缺少 LSP 工具: {expected}"
# inputSchema 保真(不压成 args)
diag_def = next(t for t in tools if t["name"] == "lsp__diagnostics")
assert "file" in diag_def["inputSchema"]["properties"]
# execute 返回真实诊断文本
res = mgr.execute("lsp__diagnostics", json.dumps({"file": str(code_file)}))
assert res.success
assert "Undefined variable" in (res.data or "")
# 上下文注入可用
ctx = mgr.get_lsp_context()
assert "Undefined variable" in ctx
finally:
mgr.shutdown()
def test_lsp_skip_when_no_servers_configured(tmp_path, monkeypatch):
# noch keinen LSP-Server -> keine lsp__ Tools
cfg = {"mcpServers": {}, "lspServers": {}}
import xcli_core.config as xcfg
monkeypatch.setattr(xcfg, "load_config", lambda: cfg)
try:
import config as root_cfg
monkeypatch.setattr(root_cfg, "load_config", lambda: cfg)
except ImportError:
pass
import unified_tool_manager as utm
mgr = utm.UnifiedToolManager()
mgr.initialize(
plugins_dir=str(tmp_path / "noplugins"),
skills_dir=str(tmp_path / "noskills"),
)
try:
names = [t["name"] for t in mgr.get_mcp_tools()]
assert not any(n.startswith("lsp__") for n in names)
finally:
mgr.shutdown()
def test_fc_tools_includes_lsp(tmp_path, monkeypatch, fake_server):
"""验证 lsp__* 工具能经 get_mcp_tools 进入 fc_tools 保真输出"""
import xcli_core.config as xcfg
cfg = {"mcpServers": {},
"lspServers": {"python": {"command": sys.executable, "args": [fake_server]}}}
monkeypatch.setattr(xcfg, "load_config", lambda: cfg)
try:
import config as root_cfg
monkeypatch.setattr(root_cfg, "load_config", lambda: cfg)
except ImportError:
pass
import unified_tool_manager as utm
import xcli_core.fc_tools as fc_tools
mgr = utm.UnifiedToolManager()
mgr.initialize(
plugins_dir=str(tmp_path / "noplugins"),
skills_dir=str(tmp_path / "noskills"),
)
try:
fc = fc_tools.mcp_to_openai_tools(mgr)
lsp_fc = [t for t in fc if t.get("function", {}).get("name", "").startswith("lsp__")]
assert len(lsp_fc) == 5, f"应进入 5 个 lsp 工具,实际 {len(lsp_fc)}"
# 保真:参数应是 file/line/character,而非单个 args
diag = next(t for t in lsp_fc if t["function"]["name"] == "lsp__diagnostics")
assert "file" in diag["function"]["parameters"]["properties"]
finally:
mgr.shutdown()