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.
248 lines
8.9 KiB
Python
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()
|