Files
clice/tests/integration/agentic/test_agentic.py
2026-06-01 00:08:12 +08:00

598 lines
21 KiB
Python

"""Tests for the agentic protocol handlers."""
import asyncio
import json
import socket
import subprocess
from concurrent.futures import ThreadPoolExecutor
import pytest
from tests.integration.utils.wait import wait_for_index
class AgenticRpcClient:
"""Minimal JSON-RPC client that speaks Content-Length framing over TCP."""
def __init__(self, host: str, port: int):
self.sock = socket.create_connection((host, port), timeout=10)
self.request_id = 0
self.buffer = b""
def request(self, method: str, params: dict):
self.request_id += 1
body = json.dumps(
{
"jsonrpc": "2.0",
"id": self.request_id,
"method": method,
"params": params,
}
)
payload = f"Content-Length: {len(body)}\r\n\r\n{body}".encode("utf-8")
self.sock.sendall(payload)
return self._read_response()
def _read_response(self):
while b"\r\n\r\n" not in self.buffer:
data = self.sock.recv(4096)
if not data:
raise ConnectionError("connection closed")
self.buffer += data
header_end = self.buffer.index(b"\r\n\r\n")
headers = self.buffer[:header_end].decode("utf-8")
self.buffer = self.buffer[header_end + 4 :]
content_length = 0
for line in headers.split("\r\n"):
if line.lower().startswith("content-length:"):
content_length = int(line.split(":")[1].strip())
while len(self.buffer) < content_length:
data = self.sock.recv(4096)
if not data:
raise ConnectionError("connection closed")
self.buffer += data
body = self.buffer[:content_length].decode("utf-8")
self.buffer = self.buffer[content_length:]
return json.loads(body)
def close(self):
self.sock.close()
def run_agentic(executable, host, port, path, timeout=10):
result = subprocess.run(
[
str(executable),
"--mode",
"agentic",
"--host",
host,
"--port",
str(port),
"--path",
path,
],
capture_output=True,
text=True,
timeout=timeout,
)
return result
@pytest.mark.workspace("hello_world")
async def test_compile_command(agentic, workspace):
executable, host, port = agentic
main_cpp = (workspace / "main.cpp").as_posix()
result = run_agentic(executable, host, port, main_cpp)
assert result.returncode == 0, f"stderr: {result.stderr}"
data = json.loads(result.stdout)
assert data["file"] == main_cpp
assert data["directory"] == workspace.as_posix()
assert len(data["arguments"]) > 0
@pytest.mark.workspace("hello_world")
async def test_compile_command_fallback(agentic, workspace):
executable, host, port = agentic
result = run_agentic(executable, host, port, "/nonexistent/file.cpp")
assert result.returncode == 0, f"stderr: {result.stderr}"
data = json.loads(result.stdout)
assert data["file"] == "/nonexistent/file.cpp"
@pytest.mark.workspace("hello_world")
async def test_multiple_requests(agentic, workspace):
executable, host, port = agentic
main_cpp = (workspace / "main.cpp").as_posix()
for _ in range(3):
result = run_agentic(executable, host, port, main_cpp)
assert result.returncode == 0, f"stderr: {result.stderr}"
data = json.loads(result.stdout)
assert data["file"] == main_cpp
async def test_connection_refused(executable):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
free_port = s.getsockname()[1]
result = run_agentic(executable, "127.0.0.1", free_port, "/some/file.cpp")
assert result.returncode != 0
@pytest.mark.workspace("hello_world")
async def test_concurrent_connections(agentic, workspace):
executable, host, port = agentic
main_cpp = (workspace / "main.cpp").as_posix()
def do_request(_):
return run_agentic(executable, host, port, main_cpp)
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(do_request, range(4)))
for r in results:
assert r.returncode == 0, f"stderr: {r.stderr}"
data = json.loads(r.stdout)
assert data["file"] == main_cpp
@pytest.fixture
async def indexed_agentic(request, executable, workspace):
"""Start server with LSP+agentic, compile a file, wait for indexing."""
from tests.integration.utils.client import CliceClient
from tests.conftest import _shutdown_client, _find_free_port
host = "127.0.0.1"
port = _find_free_port()
cmd = [str(executable), "--mode", "pipe", "--host", host, "--port", str(port)]
c = CliceClient()
await c.start_io(*cmd)
init_options = {"project": {"cache_dir": str(workspace / ".clice")}}
await c.initialize(workspace, initialization_options=init_options)
uri, _ = await c.open_and_wait(workspace / "main.cpp")
assert await wait_for_index(c, uri, "add"), "Index not ready"
rpc = AgenticRpcClient(host, port)
for _ in range(30):
resp = rpc.request("agentic/symbolSearch", {"query": "add"})
if "result" in resp and resp["result"]["symbols"]:
break
await asyncio.sleep(1)
else:
pytest.fail("agentic/symbolSearch never returned indexed symbols")
yield rpc, workspace
rpc.close()
c.close(uri)
await _shutdown_client(c)
@pytest.mark.workspace("index_features")
async def test_rpc_compile_command(indexed_agentic, workspace):
rpc, _ = indexed_agentic
path = (workspace / "main.cpp").as_posix()
resp = rpc.request("agentic/compileCommand", {"path": path})
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert result["file"] == path
assert len(result["arguments"]) > 0
@pytest.mark.workspace("index_features")
async def test_rpc_project_files(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/projectFiles", {})
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert result["total"] > 0
paths = [f["path"] for f in result["files"]]
assert any("main.cpp" in p for p in paths)
@pytest.mark.workspace("index_features")
async def test_rpc_project_files_filter(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/projectFiles", {"filter": "source"})
assert "result" in resp
for f in resp["result"]["files"]:
assert f["kind"] == "source"
@pytest.mark.workspace("index_features")
async def test_rpc_symbol_search(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/symbolSearch", {"query": "add"})
assert "result" in resp, f"unexpected response: {resp}"
symbols = resp["result"]["symbols"]
add_sym = next((s for s in symbols if s["name"] == "add"), None)
assert add_sym is not None, f"'add' not found in {[s['name'] for s in symbols]}"
assert add_sym["kind"] == "Function"
assert add_sym["line"] == 19
assert add_sym["symbolId"] != 0
assert "main.cpp" in add_sym["file"]
@pytest.mark.workspace("index_features")
async def test_rpc_symbol_search_kind(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request(
"agentic/symbolSearch", {"query": "Animal", "kindFilter": ["Struct"]}
)
assert "result" in resp
for s in resp["result"]["symbols"]:
assert s["kind"] == "Struct"
@pytest.mark.workspace("index_features")
async def test_rpc_symbol_search_max(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/symbolSearch", {"query": "", "maxResults": 3})
assert "result" in resp
assert len(resp["result"]["symbols"]) <= 3
@pytest.mark.workspace("index_features")
async def test_rpc_read_symbol(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/readSymbol", {"name": "add"})
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert result["name"] == "add"
assert result["symbolId"] != 0
assert result["startLine"] == 19
assert result["endLine"] == 21
assert "int add(int a, int b)" in result["text"]
assert "return a + b;" in result["text"]
@pytest.mark.workspace("index_features")
async def test_rpc_read_symbol_by_id(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp1 = rpc.request("agentic/readSymbol", {"name": "add"})
assert "result" in resp1
sid = resp1["result"]["symbolId"]
resp2 = rpc.request("agentic/readSymbol", {"symbolId": sid})
assert "result" in resp2
assert resp2["result"]["name"] == "add"
assert resp2["result"]["symbolId"] == sid
@pytest.mark.workspace("index_features")
async def test_rpc_document_symbols(indexed_agentic, workspace):
rpc, _ = indexed_agentic
path = (workspace / "main.cpp").as_posix()
resp = rpc.request("agentic/documentSymbols", {"path": path})
assert "result" in resp, f"unexpected response: {resp}"
symbols = resp["result"]["symbols"]
names = [s["name"] for s in symbols]
kinds = [s["kind"] for s in symbols]
assert "add" in names, f"expected 'add' in {names}"
assert "main" in names, f"expected 'main' in {names}"
assert "global_var" in names, f"expected 'global_var' in {names}"
assert "Parameter" not in kinds, (
f"Parameters should be filtered: {list(zip(names, kinds))}"
)
@pytest.mark.workspace("index_features")
async def test_rpc_definition(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/definition", {"name": "add"})
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert result["name"] == "add"
assert result["definition"] is not None
defn = result["definition"]
assert "main.cpp" in defn["file"]
assert defn["startLine"] == 19
assert defn["endLine"] == 21
assert "int add(int a, int b)" in defn["text"]
assert "return a + b;" in defn["text"]
@pytest.mark.workspace("index_features")
async def test_rpc_definition_by_position(indexed_agentic, workspace):
rpc, _ = indexed_agentic
path = (workspace / "main.cpp").as_posix()
resp = rpc.request("agentic/definition", {"path": path, "line": 19})
assert "result" in resp, f"unexpected response: {resp}"
assert resp["result"]["name"] == "add"
@pytest.mark.workspace("index_features")
async def test_rpc_references(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/references", {"name": "global_var"})
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert result["name"] == "global_var"
assert result["total"] == 2
lines = sorted(r["line"] for r in result["references"])
assert lines == [34, 38]
contexts = [r["context"] for r in result["references"]]
assert any("global_var + 1" in c for c in contexts)
assert any("global_var * 2" in c for c in contexts)
@pytest.mark.workspace("index_features")
async def test_rpc_references_include_decl(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request(
"agentic/references", {"name": "global_var", "includeDeclaration": True}
)
assert "result" in resp
result = resp["result"]
assert result["total"] == 3
lines = sorted(r["line"] for r in result["references"])
assert 31 in lines, f"expected declaration line 31 in {lines}"
@pytest.mark.workspace("index_features")
async def test_rpc_call_graph_incoming(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/callGraph", {"name": "add", "direction": "callers"})
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert result["root"]["name"] == "add"
assert result["root"]["line"] == 19
assert result["root"]["symbolId"] != 0
callers = result["callers"]
caller_names = [c["name"] for c in callers]
assert "compute" in caller_names, f"expected 'compute' in {caller_names}"
compute = next(c for c in callers if c["name"] == "compute")
assert compute["line"] == 24
assert compute["symbolId"] != 0
assert result["callees"] == []
@pytest.mark.workspace("index_features")
async def test_rpc_call_graph_outgoing(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/callGraph", {"name": "compute", "direction": "callees"})
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert result["root"]["name"] == "compute"
callees = result["callees"]
callee_names = [c["name"] for c in callees]
assert "add" in callee_names, f"expected 'add' in {callee_names}"
add_entry = next(c for c in callees if c["name"] == "add")
assert add_entry["line"] == 19
assert result["callers"] == []
@pytest.mark.workspace("index_features")
async def test_rpc_type_hierarchy_supertypes(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request(
"agentic/typeHierarchy", {"name": "Dog", "direction": "supertypes"}
)
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert result["root"]["name"] == "Dog"
assert result["root"]["line"] == 9
supertypes = result["supertypes"]
supertype_names = [t["name"] for t in supertypes]
assert "Animal" in supertype_names, f"expected 'Animal' in {supertype_names}"
animal = next(t for t in supertypes if t["name"] == "Animal")
assert animal["line"] == 2
assert animal["symbolId"] != 0
@pytest.mark.workspace("index_features")
async def test_rpc_type_hierarchy_subtypes(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request(
"agentic/typeHierarchy", {"name": "Animal", "direction": "subtypes"}
)
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert result["root"]["name"] == "Animal"
assert result["root"]["line"] == 2
subtypes = result["subtypes"]
subtype_names = [t["name"] for t in subtypes]
assert "Dog" in subtype_names, f"expected 'Dog' in {subtype_names}"
assert "Cat" in subtype_names, f"expected 'Cat' in {subtype_names}"
dog = next(t for t in subtypes if t["name"] == "Dog")
assert dog["line"] == 9
cat = next(t for t in subtypes if t["name"] == "Cat")
assert cat["line"] == 14
@pytest.mark.workspace("index_features")
async def test_rpc_status(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/status", {})
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert isinstance(result["idle"], bool)
assert result["total"] > 0
assert isinstance(result["pending"], int)
assert isinstance(result["indexed"], int)
@pytest.mark.workspace("hello_world")
async def test_rpc_shutdown(executable, workspace):
"""Shutdown notification should cause the server to exit."""
from tests.integration.utils.client import CliceClient
from tests.conftest import _shutdown_client, _find_free_port
host = "127.0.0.1"
port = _find_free_port()
cmd = [str(executable), "--mode", "pipe", "--host", host, "--port", str(port)]
c = CliceClient()
await c.start_io(*cmd)
init_options = {"project": {"cache_dir": str(workspace / ".clice")}}
await c.initialize(workspace, initialization_options=init_options)
rpc = AgenticRpcClient(host, port)
body = json.dumps({"jsonrpc": "2.0", "method": "agentic/shutdown", "params": {}})
rpc.sock.sendall(f"Content-Length: {len(body)}\r\n\r\n{body}".encode())
rpc.sock.settimeout(5)
try:
rpc.sock.recv(4096)
except (socket.timeout, OSError):
pass
rpc.sock.close()
import asyncio
for _ in range(20):
if c._server.returncode is not None:
break
await asyncio.sleep(0.5)
assert c._server.returncode is not None, "Server did not exit after shutdown"
@pytest.mark.workspace("index_features")
async def test_rpc_symbol_not_found(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/definition", {"name": "nonexistent_symbol_xyz"})
assert "error" in resp
@pytest.mark.workspace("index_features")
async def test_rpc_symbol_id_roundtrip(indexed_agentic, workspace):
"""Search -> get symbolId -> definition -> verify consistency."""
rpc, _ = indexed_agentic
search = rpc.request("agentic/symbolSearch", {"query": "compute"})
assert "result" in search
symbols = search["result"]["symbols"]
compute = next((s for s in symbols if s["name"] == "compute"), None)
assert compute is not None, f"'compute' not found in {[s['name'] for s in symbols]}"
defn = rpc.request("agentic/definition", {"symbolId": compute["symbolId"]})
assert "result" in defn
assert defn["result"]["name"] == "compute"
assert defn["result"]["symbolId"] == compute["symbolId"]
@pytest.mark.workspace("index_features")
async def test_rpc_file_deps(indexed_agentic, workspace):
rpc, _ = indexed_agentic
path = (workspace / "main.cpp").as_posix()
resp = rpc.request("agentic/fileDeps", {"path": path})
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert result["file"] == path
assert isinstance(result["includes"], list)
assert isinstance(result["includers"], list)
@pytest.mark.workspace("index_features")
async def test_rpc_file_deps_direction(indexed_agentic, workspace):
rpc, _ = indexed_agentic
path = (workspace / "main.cpp").as_posix()
resp = rpc.request("agentic/fileDeps", {"path": path, "direction": "includes"})
assert "result" in resp
assert resp["result"]["includers"] == []
@pytest.mark.workspace("index_features")
async def test_rpc_file_deps_unknown(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/fileDeps", {"path": "/nonexistent/file.cpp"})
assert "result" in resp
assert resp["result"]["includes"] == []
assert resp["result"]["includers"] == []
@pytest.mark.workspace("index_features")
async def test_rpc_impact_analysis(indexed_agentic, workspace):
rpc, _ = indexed_agentic
path = (workspace / "main.cpp").as_posix()
resp = rpc.request("agentic/impactAnalysis", {"path": path})
assert "result" in resp, f"unexpected response: {resp}"
result = resp["result"]
assert isinstance(result["directDependents"], list)
assert isinstance(result["transitiveDependents"], list)
assert isinstance(result["affectedModules"], list)
@pytest.mark.workspace("index_features")
async def test_rpc_impact_analysis_unknown(indexed_agentic, workspace):
rpc, _ = indexed_agentic
resp = rpc.request("agentic/impactAnalysis", {"path": "/nonexistent/file.cpp"})
assert "result" in resp
assert resp["result"]["directDependents"] == []
async def test_shutdown_during_indexing(executable, tmp_path):
"""Shutdown during active background indexing must exit cleanly."""
from tests.integration.utils.client import CliceClient
from tests.conftest import _find_free_port, assert_server_exited_cleanly
workspace = tmp_path / "ws"
workspace.mkdir()
entries = []
for i in range(20):
src = workspace / f"file_{i}.cpp"
src.write_text(
f"struct Type_{i} {{ int v = {i}; void m() {{}} }};\n"
f"int func_{i}(int x) {{ return x + {i}; }}\n"
f"int caller_{i}() {{ return func_{i}({i}); }}\n"
)
entries.append(
{
"directory": workspace.as_posix(),
"file": src.as_posix(),
"arguments": ["clang++", "-std=c++17", "-fsyntax-only", src.as_posix()],
}
)
(workspace / "compile_commands.json").write_text(json.dumps(entries))
host = "127.0.0.1"
port = _find_free_port()
cmd = [str(executable), "--mode", "pipe", "--host", host, "--port", str(port)]
c = CliceClient()
await c.start_io(*cmd)
try:
init_options = {
"project": {
"cache_dir": str(workspace / ".clice"),
"idle_timeout_ms": 0,
}
}
try:
await c.initialize(workspace, initialization_options=init_options)
except Exception:
if c._server.returncode is not None:
await assert_server_exited_cleanly(c._server, timeout=15.0)
raise
# Give indexing a moment to start, then send shutdown
await asyncio.sleep(0.5)
rpc = AgenticRpcClient(host, port)
body = json.dumps(
{"jsonrpc": "2.0", "method": "agentic/shutdown", "params": {}}
)
rpc.sock.sendall(f"Content-Length: {len(body)}\r\n\r\n{body}".encode())
rpc.sock.settimeout(5)
try:
rpc.sock.recv(4096)
except (socket.timeout, OSError):
pass
rpc.sock.close()
await assert_server_exited_cleanly(c._server, timeout=15.0)
finally:
c._stop_event.set()
for task in c._async_tasks:
task.cancel()
await asyncio.sleep(0.1)