File size: 9,548 Bytes
69e310f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
"""End-to-end: the whole governance chain, against live market data.

trigger → supervisor → parallel workers → writer → verify → human gate → deliver

Every assertion here is about the *chain*, not about a particular market value:
that a figure reconciles, that a fault is caught, that nothing ships without a
human. Those hold whatever the market did today.
"""

from __future__ import annotations

import asyncio
from typing import Any

import pytest

from app.core.settings import Settings
from app.models.run import HumanDecision, RunMode, RunStatus
from app.services.repository import ActiveRunExistsError
from app.services.runner import RunService

SETTLE_TIMEOUT_S = 180.0


async def _settle(service: RunService, run_id: str) -> dict[str, Any]:
    """Wait until the run leaves RUNNING."""
    deadline = asyncio.get_running_loop().time() + SETTLE_TIMEOUT_S
    while asyncio.get_running_loop().time() < deadline:
        record = await service.get_run(run_id)
        if record and record["status"] not in ("RUNNING", "QUEUED"):
            return record
        await asyncio.sleep(0.25)
    pytest.fail(f"run {run_id} did not settle within {SETTLE_TIMEOUT_S}s")


@pytest.fixture
def service(settings: Settings) -> RunService:
    return RunService(settings=settings)


class TestHappyPath:
    async def test_full_chain_from_trigger_to_delivery(self, service: RunService) -> None:
        run_id = await service.start_run(tickers=["AAPL"], trigger="test")
        record = await _settle(service, run_id)

        # 1. It paused for a human rather than delivering itself.
        assert record["status"] == RunStatus.AWAITING_APPROVAL

        gate = await service.gate_payload(run_id)
        assert gate is not None
        report = gate["verification"]

        # 2. Every numeric claim was recomputed and matched.
        assert gate["verified"] is True
        assert report["checked_claims"] > 0
        assert report["matched"] == report["checked_claims"]
        assert report["mismatched"] == 0
        assert report["coverage"] == 1.0

        # 3. The brief is well-formed and renders.
        assert gate["brief"]["snapshot"]
        assert gate["markdown"].startswith("# AlphaBrief")

        # 4. Approval releases it.
        result = await service.submit_decision(
            run_id, HumanDecision(action="approve", reviewer="tester")
        )
        assert result["status"] == RunStatus.DELIVERED
        final = await service.get_run(run_id)
        assert final is not None
        assert final["status"] == RunStatus.DELIVERED
        assert final["verified"] is True
        assert final["iterations"] >= 1

    async def test_telemetry_covers_the_whole_chain(self, service: RunService) -> None:
        run_id = await service.start_run(tickers=["MSFT"], trigger="test")
        await _settle(service, run_id)
        kinds = {str(event.kind) for event in await service.bus.history(run_id)}

        for expected in (
            "run.started",
            "supervisor.plan",
            "agent.started",
            "agent.completed",
            "mcp.tool_call",
            "writer.started",
            "verify.started",
            "verify.claim",
            "verify.completed",
            "gate.awaiting",
        ):
            assert expected in kinds, f"missing telemetry: {expected}"

        await service.submit_decision(run_id, HumanDecision(action="approve"))

    async def test_tool_calls_carry_arguments_and_timing(self, service: RunService) -> None:
        run_id = await service.start_run(tickers=["NVDA"], trigger="test")
        await _settle(service, run_id)
        events = [e for e in await service.bus.history(run_id) if str(e.kind) == "mcp.tool_call"]

        assert events
        for event in events:
            assert "tool" in event.payload
            assert "arguments" in event.payload
            assert isinstance(event.payload["duration_ms"], float)

        await service.submit_decision(run_id, HumanDecision(action="approve"))


class TestHumanGate:
    async def test_rejection_blocks_delivery(self, service: RunService) -> None:
        run_id = await service.start_run(tickers=["TSLA"], trigger="test")
        await _settle(service, run_id)

        result = await service.submit_decision(
            run_id, HumanDecision(action="reject", note="not today")
        )
        assert result["status"] == RunStatus.REJECTED

        record = await service.get_run(run_id)
        assert record is not None
        assert record["status"] == RunStatus.REJECTED

    async def test_edit_applies_narrative_only(self, service: RunService) -> None:
        run_id = await service.start_run(tickers=["AMZN"], trigger="test")
        await _settle(service, run_id)

        await service.submit_decision(
            run_id,
            HumanDecision(
                action="edit",
                edited_headline="Reviewer rewrote this headline",
                reviewer="tester",
            ),
        )
        stored = await service.repository.latest_brief(run_id)
        assert stored is not None
        assert stored["brief"]["headline"] == "Reviewer rewrote this headline"

    async def test_an_edit_containing_a_numeral_is_refused(self, service: RunService) -> None:
        """Reviewers may reword; they may not introduce an unverified figure."""
        run_id = await service.start_run(tickers=["META"], trigger="test")
        await _settle(service, run_id)

        gate_before = await service.gate_payload(run_id)
        assert gate_before is not None
        original = gate_before["brief"]["headline"]

        await service.submit_decision(
            run_id,
            HumanDecision(action="edit", edited_headline="Stock jumped 42 percent today"),
        )
        stored = await service.repository.latest_brief(run_id)
        assert stored is not None
        assert stored["brief"]["headline"] == original

    async def test_decision_on_an_unknown_run_is_refused(self, service: RunService) -> None:
        from app.services.runner import RunNotFoundError

        with pytest.raises(RunNotFoundError):
            await service.submit_decision("run_does_not_exist", HumanDecision(action="approve"))

    async def test_a_second_decision_is_refused(self, service: RunService) -> None:
        from app.services.runner import RunNotFoundError

        run_id = await service.start_run(tickers=["GOOGL"], trigger="test")
        await _settle(service, run_id)
        await service.submit_decision(run_id, HumanDecision(action="approve"))

        with pytest.raises(RunNotFoundError):
            await service.submit_decision(run_id, HumanDecision(action="approve"))


class TestFaultInjectionModes:
    async def test_demo_fault_degrades_without_crashing(self, service: RunService) -> None:
        """The interview kill shot: a fake ticker must not break the run."""
        run_id = await service.start_run(tickers=["AAPL"], mode=RunMode.DEMO_FAULT)
        record = await _settle(service, run_id)

        assert record["status"] == RunStatus.AWAITING_APPROVAL
        assert record["status"] != RunStatus.FAILED
        assert record["partial"] is True
        assert record["error_count"] > 0

        gate = await service.gate_payload(run_id)
        assert gate is not None
        brief = gate["brief"]
        assert brief["partial"] is True
        assert brief["data_gaps"]
        # The dead ticker is in the watchlist but not in the snapshot.
        assert "ZZZZQQQQ" in brief["watchlist"]
        assert "ZZZZQQQQ" not in [row["ticker"] for row in brief["snapshot"]]
        # Everything that *did* resolve still verified cleanly.
        assert gate["verified"] is True

        await service.submit_decision(run_id, HumanDecision(action="approve"))

    async def test_demo_mismatch_is_caught_and_escalated(self, service: RunService) -> None:
        """Injected bad figure: one regeneration, then HUMAN_REVIEW. Never delivered."""
        run_id = await service.start_run(tickers=["MSFT"], mode=RunMode.DEMO_MISMATCH)
        record = await _settle(service, run_id)

        assert record["status"] == RunStatus.HUMAN_REVIEW

        gate = await service.gate_payload(run_id)
        assert gate is not None
        assert gate["verified"] is False
        assert gate["requires_review"] is True
        assert gate["verification"]["mismatched"] >= 1

        events = await service.bus.history(run_id)
        # Exactly one regeneration was attempted before escalating.
        assert sum(1 for e in events if str(e.kind) == "writer.started") == 2
        assert sum(1 for e in events if str(e.kind) == "verify.completed") == 2

        await service.submit_decision(run_id, HumanDecision(action="reject", note="bad numbers"))
        final = await service.get_run(run_id)
        assert final is not None
        assert final["status"] == RunStatus.REJECTED


class TestConcurrencyGuard:
    async def test_one_active_run_per_watchlist(self, service: RunService) -> None:
        run_id = await service.start_run(tickers=["INTC"], trigger="test")
        with pytest.raises(ActiveRunExistsError):
            await service.start_run(tickers=["intc"], trigger="test")

        await _settle(service, run_id)
        await service.submit_decision(run_id, HumanDecision(action="approve"))

        # Slot released.
        second = await service.start_run(tickers=["INTC"], trigger="test")
        await _settle(service, second)
        await service.submit_decision(second, HumanDecision(action="approve"))