iai-mcp-opencode/tests/test_http_server.py
Apunkt eec312dcb4
feat(http_server): add section headers to _payload_to_text for model parsing
The flattened text output now uses markdown headers so the model can
distinguish L0 identity from L1 summary from L2 episodes from handles.
This replaces undifferentiated newline-separated text with structured
sections: ## L0 Identity, ## L1 Recent Summary, ## Rich Club,
## L2 Episode, ## Handles.
2026-06-03 16:50:02 +02:00

248 lines
8.9 KiB
Python

"""End-to-end tests for the Tier-1 localhost HTTP adapter (http_server.py).
Mirrors tests/test_daemon_dispatcher.py: boot the REAL HttpServer on an
ephemeral 127.0.0.1 port and drive it with raw HTTP/1.0 over a real TCP
socket. core.dispatch is monkeypatched to a fake so these tests exercise the
*transport adapter* (routing, error->status mapping, port-file discovery,
graceful shutdown) without needing a real MemoryStore / LanceDB.
"""
from __future__ import annotations
import asyncio
import json
import pytest
from iai_mcp.core import UnknownMethodError
def _fake_dispatch(store, method, params):
"""Stand-in for core.dispatch: deterministic, no store needed."""
if method == "session_start_payload":
return {
"l0": "pinned identity",
"l1": "recent summary",
"l2": ["episode one", "episode two"],
"rich_club": "hub concepts",
"total_cached_tokens": 0,
"total_dynamic_tokens": 1000,
# echo the override so the transport-threading test can assert it.
"wake_depth": params.get("wake_depth", "minimal"),
}
if method == "minimal_payload":
# wake_depth=minimal: content layers empty, only opaque handles set.
return {
"l0": "", "l1": "", "l2": [], "rich_club": "",
"identity_pointer": "",
"brain_handle": "<sess:abc pend:2>",
"topic_cluster_hint": "<topic:d3256b25>",
"compact_handle": "<iai:970d1b7629da7946>",
"wake_depth": "minimal",
}
if method == "needs_param":
# Simulate core.py's params["cue"] KeyError path.
return {"cue": params["cue"]}
if method == "boom":
raise RuntimeError("kaboom")
raise UnknownMethodError(method)
async def _http_request(port, method, path, body=None, *, timeout=5.0):
"""Send one HTTP/1.0 request, read the full response to EOF, parse it."""
reader, writer = await asyncio.wait_for(
asyncio.open_connection("127.0.0.1", port), timeout=timeout
)
try:
body_bytes = body.encode("utf-8") if isinstance(body, str) else (body or b"")
head = f"{method} {path} HTTP/1.0\r\n"
if body_bytes:
head += f"Content-Length: {len(body_bytes)}\r\n"
head += "Connection: close\r\n\r\n"
writer.write(head.encode("latin-1") + body_bytes)
await writer.drain()
raw = await asyncio.wait_for(reader.read(-1), timeout=timeout)
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
head_bytes, _, payload = raw.partition(b"\r\n\r\n")
lines = head_bytes.split(b"\r\n")
status = int(lines[0].decode("latin-1").split(" ")[1])
headers = {}
for line in lines[1:]:
name, _, val = line.decode("latin-1").partition(":")
headers[name.strip().lower()] = val.strip()
return status, headers, payload.decode("utf-8")
async def _with_http_server(coro_fn, *, port_file, monkeypatch):
"""Boot the real HttpServer with _fake_dispatch on an ephemeral port."""
from iai_mcp import http_server as hs
monkeypatch.setattr(hs, "dispatch", _fake_dispatch)
server = hs.HttpServer(store=object(), host="127.0.0.1", port=0, port_file=port_file)
task = asyncio.create_task(server.serve())
for _ in range(250): # wait for bind (bound_port set inside serve())
if server.bound_port is not None:
break
await asyncio.sleep(0.01)
if server.bound_port is None:
server.shutdown_event.set()
await asyncio.wait_for(task, timeout=5)
raise AssertionError("server never bound")
try:
return await coro_fn(server)
finally:
server.shutdown_event.set()
try:
await asyncio.wait_for(task, timeout=5)
except Exception:
pass
def test_healthz_returns_ok(tmp_path, monkeypatch):
async def _runner(server):
return await _http_request(server.bound_port, "GET", "/healthz")
status, headers, body = asyncio.run(
_with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch)
)
assert status == 200
assert headers["content-type"] == "application/json"
assert json.loads(body) == {"ok": True}
def test_session_context_json(tmp_path, monkeypatch):
async def _runner(server):
return await _http_request(
server.bound_port, "GET", "/memory/session-context?session_id=abc"
)
status, headers, body = asyncio.run(
_with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch)
)
assert status == 200
assert headers["content-type"] == "application/json"
payload = json.loads(body)
assert payload["l0"] == "pinned identity"
assert payload["l2"] == ["episode one", "episode two"]
def test_session_context_threads_wake_depth(tmp_path, monkeypatch):
async def _runner(server):
return await _http_request(
server.bound_port,
"GET",
"/memory/session-context?session_id=abc&wake_depth=standard",
)
status, _headers, body = asyncio.run(
_with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch)
)
assert status == 200
assert json.loads(body)["wake_depth"] == "standard"
def test_session_context_text_format(tmp_path, monkeypatch):
async def _runner(server):
return await _http_request(
server.bound_port, "GET", "/memory/session-context?format=text"
)
status, headers, body = asyncio.run(
_with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch)
)
assert status == 200
assert headers["content-type"].startswith("text/plain")
# Flattened block keeps the human layers in order, drops token ints.
assert body == (
"## L0 Identity\npinned identity\n\n## L1 Recent Summary\nrecent summary\n\n"
"## Rich Club\nhub concepts\n\n## L2 Episode\nepisode one\n\n## L2 Episode\nepisode two"
)
def test_payload_to_text_renders_handles_at_minimal():
"""At minimal wake_depth, _payload_to_text surfaces the compact handles
(content layers empty) so the injected block is never silently empty."""
from iai_mcp.http_server import _payload_to_text
text = _payload_to_text(_fake_dispatch(None, "minimal_payload", {}))
assert text == "## Handles\n<iai:970d1b7629da7946> <sess:abc pend:2> <topic:d3256b25>"
def test_rpc_post_passthrough(tmp_path, monkeypatch):
async def _runner(server):
return await _http_request(
server.bound_port,
"POST",
"/rpc",
json.dumps({"method": "session_start_payload", "params": {"session_id": "z"}}),
)
status, _headers, body = asyncio.run(
_with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch)
)
assert status == 200
assert json.loads(body)["rich_club"] == "hub concepts"
def test_unknown_route_404(tmp_path, monkeypatch):
async def _runner(server):
return await _http_request(server.bound_port, "GET", "/nope")
status, _headers, body = asyncio.run(
_with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch)
)
assert status == 404
assert "no route" in json.loads(body)["error"]
def test_unknown_method_maps_to_404(tmp_path, monkeypatch):
async def _runner(server):
return await _http_request(
server.bound_port, "POST", "/rpc", json.dumps({"method": "ghost"})
)
status, _headers, body = asyncio.run(
_with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch)
)
assert status == 404
assert "unknown method 'ghost'" in json.loads(body)["error"]
def test_internal_error_maps_to_500(tmp_path, monkeypatch):
async def _runner(server):
return await _http_request(
server.bound_port, "POST", "/rpc", json.dumps({"method": "boom"})
)
status, _headers, body = asyncio.run(
_with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch)
)
assert status == 500
assert "kaboom" in json.loads(body)["error"]
def test_wrong_verb_405(tmp_path, monkeypatch):
async def _runner(server):
return await _http_request(server.bound_port, "POST", "/healthz")
status, _headers, _body = asyncio.run(
_with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch)
)
assert status == 405
def test_port_file_written_and_cleaned(tmp_path, monkeypatch):
port_file = tmp_path / ".http.port"
async def _runner(server):
# While serving, the port-file holds the live bound port.
assert port_file.read_text() == str(server.bound_port)
return server.bound_port
asyncio.run(_with_http_server(_runner, port_file=port_file, monkeypatch=monkeypatch))
# After graceful shutdown, the discovery file is removed.
assert not port_file.exists()