"""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