Files
hunjing/backend/tests/test_kernel.py
T

167 lines
6.8 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.
"""Kernel M0 骨架测试。
覆盖 rewrite-plan M0 验收的核心面:
- 事件序列完整且有序(run.queued → run.started → … → run.finished);
- 命台席位:同人物第二个前台 Run 排队,其 run.started 晚于第一个的 run.finished;
- 瀑布否决:llm.pre_request 否决 → safety.ruled + run.interrupted(HJ-1301),无 llm.delta;
- 不变量断言:流重组不一致即 InvariantViolation;
- SSE 端到端:POST /runs → GET /runs/{id}/stream 读到 run.finished 终态。
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, AsyncIterator
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.kernel import Kernel, KernelEventLog
from app.kernel.invariants import InvariantViolation, assert_stream_reconstruction
from app.modules.runtime.events import Event
from app.runtime.bus import Veto
from app.services.event_store import DbEventStore
from app.shenshi_client import EchoShenshi, ShenshiDelta
class SlowEchoShenshi(EchoShenshi):
"""带延迟的回声桩:让命台排队与 SSE 流时序可观测。"""
delay: float = 0.15
async def stream_chat(self, request) -> AsyncIterator[ShenshiDelta]: # noqa: ANN001
async for delta in super().stream_chat(request):
await asyncio.sleep(self.delay)
yield delta
@pytest.fixture
async def kernel_runtime(monkeypatch: pytest.MonkeyPatch) -> AsyncGenerator[Kernel, None]:
"""手工装配 Kernel 到 app.state(测试不经 lifespan,与既有测试做法一致)。"""
from app.main import app
event_store = DbEventStore(AsyncSessionLocal)
kernel = Kernel(
shenshi=SlowEchoShenshi(),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(event_store),
timeout_seconds=10.0,
)
app.state.kernel = kernel
yield kernel
app.state.kernel = None
@pytest.fixture
async def kernel_client(
kernel_runtime: Kernel, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
async def _wait_status(client: AsyncClient, run_id: str, target: set[str]) -> dict:
for _ in range(500):
resp = await client.get(f"/api/v1/runs/{run_id}")
body = resp.json()
if body["status"] in target:
return body
await asyncio.sleep(0.02)
raise AssertionError(f"Run 未到达终态:{run_id}")
async def _events(client: AsyncClient, run_id: str) -> list[dict]:
resp = await client.get(f"/api/v1/runs/{run_id}/events")
assert resp.status_code == 200
return resp.json()
async def test_placeholder_run_event_sequence(kernel_client: AsyncClient) -> None:
created = await kernel_client.post("/api/v1/runs", json={"prompt": "你好"})
assert created.status_code == 200
run_id = created.json()["id"]
final = await _wait_status(kernel_client, run_id, {"completed"})
assert final["origin"] == "kernel"
types = [e["type"] for e in await _events(kernel_client, run_id)]
assert types[0] == "run.queued"
assert "run.started" in types
order = {t: types.index(t) for t in ["run.started", "context.projected", "llm.requested", "llm.completed", "result.accepted", "run.finished"]}
assert order["run.started"] < order["context.projected"] < order["llm.requested"]
assert order["llm.completed"] < order["result.accepted"] < order["run.finished"]
assert types[-1] == "run.finished"
assert types.count("llm.delta") >= 1
# "模型可见即可重建":llm.requested 事件携带完整 IR 消息
requested = next(e for e in await _events(kernel_client, run_id) if e["type"] == "llm.requested")
messages = requested["payload"]["request"]["messages"]
assert messages[-1]["content"] == "你好"
async def test_seat_serializes_foreground(kernel_client: AsyncClient) -> None:
first = (await kernel_client.post("/api/v1/runs", json={"prompt": "第一个"})).json()
second = (await kernel_client.post("/api/v1/runs", json={"prompt": "第二个"})).json()
await _wait_status(kernel_client, first["id"], {"completed"})
await _wait_status(kernel_client, second["id"], {"completed"})
def interval(events: list[dict]) -> tuple[str, str]:
started = next(e["created_at"] for e in events if e["type"] == "run.started")
finished = next(e["created_at"] for e in events if e["type"] == "run.finished")
return started, finished
start1, finish1 = interval(await _events(kernel_client, first["id"]))
start2, finish2 = interval(await _events(kernel_client, second["id"]))
# 同人物前台串行:两个执行区间不相交(谁先谁后由调度决定)
late_start, early_finish = max(start1, start2), min(finish1, finish2)
assert late_start >= early_finish
async def test_veto_interrupts_run(
kernel_runtime: Kernel, kernel_client: AsyncClient
) -> None:
async def veto_llm(node: str, context) -> None: # noqa: ANN001, ARG001
raise Veto("测试否决:禁止本次模型调用", rule="test_rule")
kernel_runtime.bus.on("llm.pre_request", veto_llm)
try:
created = await kernel_client.post("/api/v1/runs", json={"prompt": "被否决"})
run_id = created.json()["id"]
final = await _wait_status(kernel_client, run_id, {"interrupted"})
assert final["error_code"] == "HJ-1301"
types = [e["type"] for e in await _events(kernel_client, run_id)]
assert "safety.ruled" in types
assert "llm.delta" not in types
assert types[-1] == "run.interrupted"
finally:
kernel_runtime.bus._listeners.pop("llm.pre_request", None) # noqa: SLF001
def test_stream_reconstruction_invariant() -> None:
def ev(delta: str) -> Event:
return Event(type="llm.delta", source="kernel", payload={"delta": delta})
assert_stream_reconstruction([ev("你"), ev("好")], "你好")
with pytest.raises(InvariantViolation):
assert_stream_reconstruction([ev("你"), ev("好")], "你好吗")
async def test_sse_stream_end_to_end(kernel_client: AsyncClient) -> None:
created = await kernel_client.post("/api/v1/runs", json={"prompt": "流式验证"})
run_id = created.json()["id"]
seen_types: list[str] = []
async with kernel_client.stream("GET", f"/api/v1/runs/{run_id}/stream") as response:
assert response.status_code == 200
async for line in response.aiter_lines():
if line.startswith("event:"):
seen_types.append(line[len("event:") :].strip())
if line.startswith("data:") and '"run.finished"' in line:
break
assert "run.started" in seen_types
assert "llm.delta" in seen_types
assert "run.finished" in seen_types