"""Tests for ``stirling.agents.math_presentation``. Only one helper lives in this module now: Verdict-artifact extraction on the resume turn. Math intent itself is decided by the orchestrator's top-level LLM and passed in as a flag, so there's no English regex to test here. Verdict → prose / sticky-note text are the consumer agents' responsibility — those projections are tested with each consumer. """ from __future__ import annotations import pytest from pydantic import ValidationError from stirling.agents.math_presentation import extract_math_verdict from stirling.contracts import ( AiFile, ExtractedFileText, ExtractedTextArtifact, MathAuditorToolReportArtifact, OrchestratorRequest, WorkflowArtifact, ) from stirling.contracts.ledger import Discrepancy, DiscrepancyKind, Severity, Verdict from stirling.models import FileId def _make_verdict(discrepancies: list[Discrepancy]) -> Verdict: return Verdict( session_id="s1", discrepancies=discrepancies, pages_examined=[d.page for d in discrepancies] or [0], rounds_taken=1, summary="Test verdict.", clean=not discrepancies, ) # --------------------------------------------------------------------------- # Resume-turn round-trip — ToolReportArtifact → Verdict # --------------------------------------------------------------------------- def _orchestrator_request_with_artifacts(artifacts: list[WorkflowArtifact]) -> OrchestratorRequest: return OrchestratorRequest( user_message="review the math", files=[AiFile(id=FileId("report-id"), name="report.pdf")], artifacts=artifacts, ) def test_extract_math_verdict_roundtrips_a_math_auditor_report() -> None: """When the math auditor has already run, Java re-enters the orchestrator with a ToolReportArtifact carrying the serialised Verdict; the meta-agent's first job on the resume turn is to hydrate that back into a Verdict.""" original = _make_verdict( [ Discrepancy( page=0, kind=DiscrepancyKind.TALLY, severity=Severity.ERROR, description="Total mismatch.", stated="$215,000", expected="$215,500", context="Revenue row", ) ] ) artifact = MathAuditorToolReportArtifact(report=original) request = _orchestrator_request_with_artifacts([artifact]) verdict = extract_math_verdict(request) assert verdict is not None assert len(verdict.discrepancies) == 1 assert verdict.discrepancies[0].stated == "$215,000" assert verdict.discrepancies[0].expected == "$215,500" def test_extract_math_verdict_returns_none_when_no_artifacts_present() -> None: """First turn — the plan has not yet run, so artifacts is empty.""" request = _orchestrator_request_with_artifacts([]) assert extract_math_verdict(request) is None def test_extract_math_verdict_ignores_other_artifact_kinds() -> None: """Only MathAuditorToolReportArtifact counts. Other artifact kinds (e.g. extracted page text from a NeedContent round-trip) must be ignored here so meta-agents don't misinterpret them as math reports.""" unrelated = ExtractedTextArtifact( files=[ExtractedFileText(file_name="report.pdf", pages=[])], ) request = _orchestrator_request_with_artifacts([unrelated]) assert extract_math_verdict(request) is None def test_malformed_math_auditor_report_is_rejected_at_validation_time() -> None: """The discriminated-union contract validates the report payload as a :class:`Verdict` on receipt — a corrupt body raises at construction time rather than silently surviving until the meta-agent tries to read it.""" with pytest.raises(ValidationError): MathAuditorToolReportArtifact.model_validate( { "kind": "tool_report", "source_tool": "math_auditor_agent", "report": {"not_a_verdict_field": "garbage"}, } )