dc00a4f8f5
Wave 5-1 mypy adoption (D-058 relaxed-first): mypy 2.3.1, [tool.mypy] check_untyped_defs + no_implicit_optional + warn_unused_ignores + warn_redundant_casts + warn_unreachable, no overrides (every dep ships py.typed), files = ai_service + scripts/sandbox-agent.py + tests (not excluded). 215 errors → 0: ~140 fixed at source incl. genuine bugs found by the gate (LLMProvider protocol async-generator shape, SessionStore protocol sync/async mismatch, structured_reply genericity, get_args migrations, sandbox-agent Optional guard honesty, proctor test arg-order, dead ExecResult kwarg, 16 sqlmodel col() sites); 34 live suppressions scoped-coded + reasoned. Original 27 type:ignore dispositioned: 12 dead removed, 15 scoped (warn_unused_ignores self-eliminates the rest). Wired: scripts/typecheck.sh + package.json + turbo task + root ai:typecheck — pnpm typecheck now 5 tsc + mypy. Wave 5-2 recharts 2.15.4 → 3.10.1 (D-049/D-059): one breaking class (Tooltip formatter ValueType typing); 2 one-line inference fixes on the 2 chart files; no rollback needed (well inside the G-106 timebox). Deprecated direct dep resolved; uuid@9 gone from the tree. Build 248/248 with chart SSR markers verified in exported employer + dashboard pages. Wave 5-3 deprecated-transitive audit (G-109): 4 lockfile entries remain, all dev-only, none actionable within NFR budget — glob@7.2.3 + inflight + rimraf@3 via Storybook docgen chain (flat-cache@3); glob@10.5.0 via c8→test-exclude (D-052-binding coverage tool — swapping it is out of budget). Python: no EOL packages; zero-warning regime under filterwarnings=error is the machine-checked proof. Counts: 670 py / 44 web / 64 cli, zero warnings, G-104 allowlist still exactly 2. ---ci--- phase: 5 milestone: v0.7 status: execute ---/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"))
|
|
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
|