1a88ab3199
The v0.8 milestone: installable on any Debian-based server via docker (Gitea registry image, honest sandbox degrade), deb (amd64+arm64, advisory postinst), and arch-aware binary — with release consistency enforced by pre-commit hooks + Gitea Actions (3-runner fleet on CT 113, machine-made releases with the full 8-asset matrix + registry image), the CLI carrying the full suite (guide, channel-aware doctor), and the admin runbook (deploy/README.md, evidence-backed recipes). ---ci--- phase: 6 milestone: v0.8 status: complete requirements: covered: [REQ-8-001, REQ-8-002, REQ-8-003, REQ-8-004, REQ-8-005] partial: [] ---/ci---
88 lines
3.1 KiB
Python
88 lines
3.1 KiB
Python
"""Lab endpoint tests — LIVE trace contract (REQ-3-007).
|
|
|
|
v0.3 re-grounding: POST /v1/lab/feedback takes {learner_id, task_id}; the
|
|
digest is computed from the learner's real TraceStore events. No corpus
|
|
scenarios; empty trace is a valid "no telemetry yet" coaching path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from ai_service.config import Settings
|
|
from ai_service.grading.store import SQLiteGradeStore
|
|
from ai_service.llm.mock import MockProvider
|
|
from ai_service.main import create_app
|
|
from ai_service.telemetry.ingest import TraceIntegrityMap
|
|
from ai_service.telemetry.models import TelemetryEvent
|
|
from ai_service.telemetry.store import SQLiteTraceStore
|
|
|
|
T0 = datetime(2026, 9, 12, tzinfo=UTC)
|
|
|
|
|
|
def _event(seq: int, kind: str, payload: dict, offset_s: float):
|
|
return TelemetryEvent(
|
|
learner_id="lab-learner",
|
|
task_id="lab-task",
|
|
seq=seq,
|
|
kind=kind,
|
|
payload=payload,
|
|
ts=T0 + timedelta(seconds=offset_s),
|
|
sandbox_id="sbx-lab",
|
|
)
|
|
|
|
|
|
class StreamingMock(MockProvider):
|
|
"""Deterministic token stream for the SSE path."""
|
|
|
|
def _reply_for(self, messages, response_format):
|
|
return "Feedback grounded in your live session digest."
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(tmp_path: Path) -> Iterator[TestClient]:
|
|
app = create_app(Settings(provider="mock", db_path=tmp_path / "app.db"))
|
|
app.state.provider = StreamingMock()
|
|
app.state.trace_store = SQLiteTraceStore(db_path=tmp_path / "t.db")
|
|
app.state.grade_store = SQLiteGradeStore(db_path=tmp_path / "g.db")
|
|
app.state.trace_integrity = TraceIntegrityMap()
|
|
with TestClient(app) as c:
|
|
yield c
|
|
|
|
|
|
def test_live_trace_streams_full_envelope(client: TestClient) -> None:
|
|
store: SQLiteTraceStore = client.app.state.trace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
|
|
for e in [
|
|
_event(0, "file_diff", {"path": "x.py"}, 0),
|
|
_event(1, "command", {"cmd": "pytest -q"}, 10),
|
|
_event(2, "test_result", {"passed": False, "exit_code": 1}, 20),
|
|
_event(3, "test_result", {"passed": True, "exit_code": 0}, 40),
|
|
]:
|
|
store.append(e)
|
|
with client.stream(
|
|
"POST", "/v1/lab/feedback", json={"learner_id": "lab-learner", "task_id": "lab-task"}
|
|
) as resp:
|
|
assert resp.status_code == 200
|
|
body = "".join(chunk.decode() for chunk in resp.iter_raw())
|
|
assert '"agent": "lab"' in body or '"agent":"lab"' in body
|
|
assert '"task_id": "lab-task"' in body
|
|
assert '"type": "delta"' in body or '"type":"delta"' in body
|
|
|
|
|
|
def test_empty_trace_coaches_the_baseline(client: TestClient) -> None:
|
|
"""No telemetry is NOT an error — Lab coaches 'run the starter test'."""
|
|
with client.stream(
|
|
"POST", "/v1/lab/feedback", json={"learner_id": "lab-learner", "task_id": "no-events"}
|
|
) as resp:
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_missing_fields_422(client: TestClient) -> None:
|
|
resp = client.post("/v1/lab/feedback", json={"learner_id": "x"})
|
|
assert resp.status_code == 422
|