FastAPI app, upstream Confluence/model adapters, authoritative history, rootless container lifecycle, artifact storage and downloads, fake peers under backend/dev, tests under tests/backend. Root pytest.ini deselects the live marker by default; requirements gain the backend dependencies.
155 lines
4.9 KiB
Python
155 lines
4.9 KiB
Python
"""Unit tests for NDJSON bridge transport and protocol state machine."""
|
|
|
|
import asyncio
|
|
import json
|
|
import pytest
|
|
|
|
from backend.transport import BridgeState, BridgeTransport, NDJSONProtocolError
|
|
|
|
|
|
class MockStream:
|
|
def __init__(self):
|
|
self._queue = asyncio.Queue()
|
|
|
|
async def readline(self) -> bytes:
|
|
data = await self._queue.get()
|
|
return data
|
|
|
|
def write(self, data: bytes) -> None:
|
|
self._queue.put_nowait(data)
|
|
|
|
async def drain(self) -> None:
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_lifecycle():
|
|
to_backend = MockStream()
|
|
to_agent = MockStream()
|
|
|
|
backend_transport = BridgeTransport(reader=to_backend, writer=to_agent)
|
|
|
|
# 1. Start from INIT
|
|
assert backend_transport.state == BridgeState.INIT
|
|
await backend_transport.send_start(
|
|
prompt="Test prompt",
|
|
system_instruction="System",
|
|
remaining_ms=180000,
|
|
model_descriptor={"id": "test-model", "context_window_tokens": 1000, "max_output_tokens": 500},
|
|
)
|
|
assert backend_transport.state == BridgeState.RUNNING
|
|
|
|
# 2. Agent sends tool request
|
|
to_backend.write(
|
|
json.dumps({
|
|
"v": 1,
|
|
"type": "tool_request",
|
|
"id": "a_1",
|
|
"payload": {"tool": "confluence_view", "parameters": {"page_id": "123"}},
|
|
}).encode("utf-8") + b"\n"
|
|
)
|
|
|
|
msg = await backend_transport.read_message_bounded()
|
|
assert msg["type"] == "tool_request"
|
|
assert msg["id"] == "a_1"
|
|
assert "a_1" in backend_transport._inflight_calls
|
|
|
|
# 3. Agent sends collection_start while tool call in flight -> should fail!
|
|
to_backend.write(
|
|
json.dumps({
|
|
"v": 1,
|
|
"type": "collection_start",
|
|
"id": "a_2",
|
|
"payload": {"markdown": "# Done", "warnings": []},
|
|
}).encode("utf-8") + b"\n"
|
|
)
|
|
|
|
with pytest.raises(NDJSONProtocolError, match="still in flight"):
|
|
await backend_transport.read_message_bounded()
|
|
|
|
# 4. Backend answers tool request
|
|
await backend_transport.send_tool_response(
|
|
reply_to="a_1",
|
|
result={"page_id": "123", "markdown": "content", "truncated": False},
|
|
error=None,
|
|
)
|
|
assert "a_1" not in backend_transport._inflight_calls
|
|
|
|
# 5. Now agent sends collection_start -> transition to COLLECTING
|
|
to_backend.write(
|
|
json.dumps({
|
|
"v": 1,
|
|
"type": "collection_start",
|
|
"id": "a_3",
|
|
"payload": {"markdown": "# Done", "warnings": []},
|
|
}).encode("utf-8") + b"\n"
|
|
)
|
|
|
|
c_msg = await backend_transport.read_message_bounded()
|
|
assert c_msg["type"] == "collection_start"
|
|
assert backend_transport.state == BridgeState.COLLECTING
|
|
|
|
# 6. Backend sends collection_ready
|
|
await backend_transport.send_collection_ready(reply_to="a_3")
|
|
|
|
# 7. Agent sends complete -> transition to COMPLETE
|
|
to_backend.write(
|
|
json.dumps({
|
|
"v": 1,
|
|
"type": "complete",
|
|
"id": "a_4",
|
|
"payload": {"accepted_transfer_count": 0},
|
|
}).encode("utf-8") + b"\n"
|
|
)
|
|
|
|
comp_msg = await backend_transport.read_message_bounded()
|
|
assert comp_msg["type"] == "complete"
|
|
assert backend_transport.state == BridgeState.COMPLETE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_message_ids():
|
|
to_backend = MockStream()
|
|
to_agent = MockStream()
|
|
transport = BridgeTransport(reader=to_backend, writer=to_agent)
|
|
await transport.send_start("p", "s", 1000, {"id": "m", "context_window_tokens": 1, "max_output_tokens": 1})
|
|
|
|
# First message with ID a_1
|
|
to_backend.write(
|
|
json.dumps({"v": 1, "type": "tool_request", "id": "a_1", "payload": {"tool": "t", "parameters": {}}}).encode()
|
|
+ b"\n"
|
|
)
|
|
await transport.read_message_bounded()
|
|
|
|
# Duplicate message with same ID a_1
|
|
to_backend.write(
|
|
json.dumps({"v": 1, "type": "tool_request", "id": "a_1", "payload": {"tool": "t", "parameters": {}}}).encode()
|
|
+ b"\n"
|
|
)
|
|
with pytest.raises(NDJSONProtocolError, match="Duplicate message ID"):
|
|
await transport.read_message_bounded()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_inflight_remote_calls():
|
|
to_backend = MockStream()
|
|
to_agent = MockStream()
|
|
transport = BridgeTransport(reader=to_backend, writer=to_agent)
|
|
await transport.send_start("p", "s", 1000, {"id": "m", "context_window_tokens": 1, "max_output_tokens": 1})
|
|
|
|
# Send 4 in-flight requests (allowed)
|
|
for i in range(4):
|
|
to_backend.write(
|
|
json.dumps({"v": 1, "type": "tool_request", "id": f"a_{i}", "payload": {"tool": "t", "parameters": {}}}).encode()
|
|
+ b"\n"
|
|
)
|
|
await transport.read_message_bounded()
|
|
|
|
# 5th in-flight request -> exceeds 4
|
|
to_backend.write(
|
|
json.dumps({"v": 1, "type": "tool_request", "id": "a_5", "payload": {"tool": "t", "parameters": {}}}).encode()
|
|
+ b"\n"
|
|
)
|
|
with pytest.raises(NDJSONProtocolError, match="in-flight"):
|
|
await transport.read_message_bounded()
|