| 1 | """U1 - discovery handoff file contracts for the three-leg host-judged protocol. |
| 2 | |
| 3 | Leg 1 (``--discover --nominate-only``) writes a nominations bundle carrying |
| 4 | the FULL judge pool losslessly; leg 2 (``--discover --judgments <file>``) |
| 5 | binds host judgments to that bundle by bundle_id; leg 3 (``--discover |
| 6 | --finalize [--angles <file>]``) applies host-written angles. This file pins |
| 7 | the bundle writer/reader round-trip, strict-top-level / lenient-per-row |
| 8 | reader semantics, TTL and version gating, sanitation, collision handling, |
| 9 | and the host-facing digest. |
| 10 | """ |
| 11 | |
| 12 | import inspect |
| 13 | import json |
| 14 | import os |
| 15 | from datetime import datetime, timedelta, timezone |
| 16 | |
| 17 | import pytest |
| 18 | |
| 19 | from lib import discovery_handoff as handoff |
| 20 | from lib import pipeline, rerank, schema |
| 21 | |
| 22 | |
| 23 | def _item( |
| 24 | item_id: str, |
| 25 | source: str, |
| 26 | title: str, |
| 27 | *, |
| 28 | published_at: str = "2026-07-18", |
| 29 | engagement: dict[str, int | float] | None = None, |
| 30 | snippet: str = "", |
| 31 | metadata: dict | None = None, |
| 32 | ) -> schema.SourceItem: |
| 33 | return schema.SourceItem( |
| 34 | item_id=item_id, |
| 35 | source=source, |
| 36 | title=title, |
| 37 | body=title, |
| 38 | url=f"https://{source}.example/{item_id}", |
| 39 | published_at=published_at, |
| 40 | engagement=engagement or {}, |
| 41 | snippet=snippet or f"Evidence about {title}", |
| 42 | metadata=metadata or {}, |
| 43 | ) |
| 44 | |
| 45 | |
| 46 | def _nomination( |
| 47 | name: str, |
| 48 | items: list[schema.SourceItem], |
| 49 | *, |
| 50 | seed_score: float = 42.5, |
| 51 | summary: str = "", |
| 52 | junk_shape: bool = False, |
| 53 | worthiness: float | None = None, |
| 54 | ) -> pipeline.Nomination: |
| 55 | return pipeline.Nomination( |
| 56 | name=name, |
| 57 | seed_score=seed_score, |
| 58 | items=items, |
| 59 | summary=summary or f"Summary of {name}", |
| 60 | junk_shape=junk_shape, |
| 61 | worthiness=worthiness, |
| 62 | ) |
| 63 | |
| 64 | |
| 65 | def _entry( |
| 66 | nomination: pipeline.Nomination, |
| 67 | *, |
| 68 | cluster_id: str = "c1", |
| 69 | heuristic_name: str | None = None, |
| 70 | heuristic_junk: bool = False, |
| 71 | ) -> "handoff.PoolEntry": |
| 72 | return handoff.PoolEntry( |
| 73 | nomination=nomination, |
| 74 | cluster_id=cluster_id, |
| 75 | heuristic_name=heuristic_name if heuristic_name is not None else nomination.name, |
| 76 | heuristic_junk=heuristic_junk, |
| 77 | ) |
| 78 | |
| 79 | |
| 80 | def _pool() -> list["handoff.PoolEntry"]: |
| 81 | agent = _nomination( |
| 82 | "Agent SDK Wars", |
| 83 | [ |
| 84 | _item( |
| 85 | "hn1", "hackernews", |
| 86 | "Agent SDK Wars heat up as Anthropic ships a Claude agent runtime", |
| 87 | engagement={"points": 900, "comments": 400}, |
| 88 | ), |
| 89 | _item( |
| 90 | "rd1", "reddit", |
| 91 | "Agent SDK wars: which runtime are you betting on?", |
| 92 | engagement={"score": 300, "num_comments": 80}, |
| 93 | metadata={"top_comments": [{ |
| 94 | "excerpt": "The SDK churn is unsustainable for small teams", |
| 95 | "score": 1635, |
| 96 | "author": "dev_a", |
| 97 | }]}, |
| 98 | ), |
| 99 | ], |
| 100 | seed_score=61.2, |
| 101 | ) |
| 102 | quantum = _nomination( |
| 103 | "Quantum Error Correction", |
| 104 | [ |
| 105 | _item( |
| 106 | "hn2", "hackernews", |
| 107 | "Quantum error correction milestone announced", |
| 108 | engagement={"points": 250, "comments": 60}, |
| 109 | ), |
| 110 | ], |
| 111 | seed_score=18.4, |
| 112 | ) |
| 113 | return [ |
| 114 | _entry(agent, cluster_id="c-agent", heuristic_junk=False), |
| 115 | _entry(quantum, cluster_id="c-quantum", heuristic_junk=True), |
| 116 | ] |
| 117 | |
| 118 | |
| 119 | def _write(config_dir, entries=None, **overrides) -> "handoff.NominationsBundle": |
| 120 | kwargs = dict( |
| 121 | domain="AI", |
| 122 | tier="deep", |
| 123 | from_date="2026-06-21", |
| 124 | to_date="2026-07-21", |
| 125 | lookback_days=30, |
| 126 | enrichment_source_boundary=None, |
| 127 | requested_sources=["hackernews", "reddit"], |
| 128 | save_dir=None, |
| 129 | config_dir=config_dir, |
| 130 | ) |
| 131 | kwargs.update(overrides) |
| 132 | return handoff.write_nominations_bundle( |
| 133 | entries if entries is not None else _pool(), **kwargs |
| 134 | ) |
| 135 | |
| 136 | |
| 137 | def _judgments_file(tmp_path, payload) -> "Path": |
| 138 | path = tmp_path / "judgments.json" |
| 139 | path.write_text(json.dumps(payload), encoding="utf-8") |
| 140 | return path |
| 141 | |
| 142 | |
| 143 | # --- Scenario 1: bundle round-trip ------------------------------------------ |
| 144 | |
| 145 | |
| 146 | def test_bundle_round_trip_is_lossless(tmp_path): |
| 147 | written = _write(tmp_path) |
| 148 | read = handoff.read_nominations_bundle(save_dir=None, config_dir=tmp_path) |
| 149 | assert read.bundle_id == written.bundle_id |
| 150 | assert read.schema_version == schema.DISCOVERY_NOMINATIONS_SCHEMA_VERSION |
| 151 | assert (read.from_date, read.to_date) == ("2026-06-21", "2026-07-21") |
| 152 | assert read.domain == "AI" |
| 153 | assert read.tier == "deep" |
| 154 | assert read.lookback_days == 30 |
| 155 | assert read.enrichment_source_boundary is None |
| 156 | assert read.requested_sources == ["hackernews", "reddit"] |
| 157 | assert [row.nomination_id for row in read.nominations] == ["n1", "n2"] |
| 158 | for row, entry in zip(read.nominations, _pool()): |
| 159 | # Full dataclass equality: name, seed_score, every seed item field, |
| 160 | # summary, junk_shape, worthiness. |
| 161 | assert row.nomination == entry.nomination |
| 162 | assert row.cluster_id == entry.cluster_id |
| 163 | assert row.heuristic_name == entry.heuristic_name |
| 164 | assert row.heuristic_junk == entry.heuristic_junk |
| 165 | assert row.sources == sorted({i.source for i in entry.nomination.items}) |
| 166 | assert read.path == tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME |
| 167 | |
| 168 | |
| 169 | def test_save_dir_takes_precedence_over_config_dir(tmp_path): |
| 170 | save_dir = tmp_path / "saves" |
| 171 | config_dir = tmp_path / "config" |
| 172 | written = _write(config_dir, save_dir=save_dir) |
| 173 | assert written.path == save_dir / handoff.NOMINATIONS_BUNDLE_FILENAME |
| 174 | read = handoff.read_nominations_bundle(save_dir=save_dir, config_dir=config_dir) |
| 175 | assert read.bundle_id == written.bundle_id |
| 176 | |
| 177 | |
| 178 | def test_source_boundary_and_shallow_tier_survive_round_trip(tmp_path): |
| 179 | _write( |
| 180 | tmp_path, |
| 181 | tier="shallow", |
| 182 | enrichment_source_boundary=["reddit", "hackernews"], |
| 183 | requested_sources=None, |
| 184 | lookback_days=7, |
| 185 | ) |
| 186 | read = handoff.read_nominations_bundle(config_dir=tmp_path) |
| 187 | assert read.tier == "shallow" |
| 188 | assert read.enrichment_source_boundary == ["reddit", "hackernews"] |
| 189 | assert read.requested_sources is None |
| 190 | assert read.lookback_days == 7 |
| 191 | |
| 192 | |
| 193 | def test_bundle_round_trips_sweep_source_status_and_mock_flag(tmp_path): |
| 194 | """F1a/F19: the leg-1 sweep's per-source outcomes (including degraded |
| 195 | states) and the mock provenance flag ride in the bundle so legs 2-3 can |
| 196 | restore them - reusing the schema round-trip, never a parallel shape.""" |
| 197 | status = { |
| 198 | "hackernews": schema.SourceOutcome( |
| 199 | source="hackernews", state="ok", items_returned=2, |
| 200 | at="2026-07-21T00:00:00Z", |
| 201 | ), |
| 202 | "reddit": schema.SourceOutcome( |
| 203 | source="reddit", state=schema.UNREACHABLE, detail="dns failure", |
| 204 | at="2026-07-21T00:00:01Z", fix_hint="doctor", |
| 205 | ), |
| 206 | } |
| 207 | written = _write(tmp_path, source_status=status, mock=True) |
| 208 | assert written.source_status == status |
| 209 | assert written.mock is True |
| 210 | read = handoff.read_nominations_bundle(config_dir=tmp_path) |
| 211 | assert read.source_status == status |
| 212 | assert read.mock is True |
| 213 | |
| 214 | |
| 215 | def test_bundle_reader_defaults_source_status_and_mock_for_older_files(tmp_path): |
| 216 | """Older bundles carry neither key: restore an empty status map and a |
| 217 | real (mock=False) provenance.""" |
| 218 | _write(tmp_path) |
| 219 | path = tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME |
| 220 | payload = json.loads(path.read_text(encoding="utf-8")) |
| 221 | payload.pop("source_status", None) |
| 222 | payload.pop("mock", None) |
| 223 | path.write_text(json.dumps(payload), encoding="utf-8") |
| 224 | read = handoff.read_nominations_bundle(config_dir=tmp_path) |
| 225 | assert read.source_status == {} |
| 226 | assert read.mock is False |
| 227 | |
| 228 | |
| 229 | # --- Scenario 2: parity pin -------------------------------------------------- |
| 230 | |
| 231 | |
| 232 | def test_parity_floor_and_velocity_inputs_survive_round_trip(tmp_path): |
| 233 | entries = _pool() |
| 234 | _write(tmp_path, entries) |
| 235 | read = handoff.read_nominations_bundle(config_dir=tmp_path) |
| 236 | for row, entry in zip(read.nominations, entries): |
| 237 | before, after = entry.nomination.items, row.nomination.items |
| 238 | assert len(after) == len(before) |
| 239 | assert [i.engagement for i in after] == [i.engagement for i in before] |
| 240 | assert [i.source for i in after] == [i.source for i in before] |
| 241 | assert [i.published_at for i in after] == [i.published_at for i in before] |
| 242 | # Entity-token disambiguation inputs (title + snippet) are lossless. |
| 243 | assert [(i.title, i.snippet) for i in after] == [ |
| 244 | (i.title, i.snippet) for i in before |
| 245 | ] |
| 246 | # Velocity and floor inputs recompute identically to an in-memory run. |
| 247 | assert rerank.discovery_velocity_score( |
| 248 | after, as_of_date="2026-07-21" |
| 249 | ) == rerank.discovery_velocity_score(before, as_of_date="2026-07-21") |
| 250 | assert sum(rerank.discovery_engagement_total(i) for i in after) == sum( |
| 251 | rerank.discovery_engagement_total(i) for i in before |
| 252 | ) |
| 253 | assert {i.source for i in after} == {i.source for i in before} |
| 254 | |
| 255 | |
| 256 | # --- Scenario 3: judgments reader -------------------------------------------- |
| 257 | |
| 258 | |
| 259 | def test_judgments_apply_by_id_with_per_row_leniency(tmp_path, capsys): |
| 260 | bundle = _write(tmp_path) |
| 261 | path = _judgments_file(tmp_path, { |
| 262 | "bundle_id": bundle.bundle_id, |
| 263 | "judgments": [ |
| 264 | {"id": "n1", "name": "Claude Agent Runtime Launch", "junk": False, |
| 265 | "worthiness": 78}, |
| 266 | {"id": "n9", "name": "Ghost Topic", "worthiness": 50}, |
| 267 | ], |
| 268 | }) |
| 269 | judgments = handoff.read_judgments( |
| 270 | path, bundle, save_dir=None, config_dir=tmp_path |
| 271 | ) |
| 272 | assert judgments["n1"] == handoff.HostJudgment( |
| 273 | name="Claude Agent Runtime Launch", junk=False, worthiness=78, |
| 274 | ) |
| 275 | # Unknown id: warned (always visible, tty_only=False) and ignored. |
| 276 | assert "n9" not in judgments |
| 277 | assert "n9" in capsys.readouterr().err |
| 278 | # n2 omitted entirely -> per-row-absent marker; the caller falls back to |
| 279 | # the bundle's heuristic name/junk. |
| 280 | assert handoff.judgment_for(judgments, "n2") is handoff.ROW_ABSENT |
| 281 | assert handoff.ROW_ABSENT.name is None |
| 282 | assert handoff.ROW_ABSENT.junk is None |
| 283 | assert handoff.ROW_ABSENT.worthiness is None |
| 284 | |
| 285 | |
| 286 | def test_judgments_worthiness_clamped_to_0_100_integers(tmp_path): |
| 287 | bundle = _write(tmp_path) |
| 288 | path = _judgments_file(tmp_path, { |
| 289 | "bundle_id": bundle.bundle_id, |
| 290 | "judgments": [ |
| 291 | {"id": "n1", "worthiness": 150}, |
| 292 | {"id": "n2", "worthiness": -3.7}, |
| 293 | ], |
| 294 | }) |
| 295 | judgments = handoff.read_judgments(path, bundle) |
| 296 | assert judgments["n1"].worthiness == 100 |
| 297 | assert judgments["n2"].worthiness == 0 |
| 298 | assert isinstance(judgments["n1"].worthiness, int) |
| 299 | # No name on either row -> per-row-absent name and junk. |
| 300 | assert judgments["n1"].name is None |
| 301 | assert judgments["n1"].junk is None |
| 302 | |
| 303 | |
| 304 | def test_junk_accepted_even_without_usable_name(tmp_path): |
| 305 | bundle = _write(tmp_path) |
| 306 | path = _judgments_file(tmp_path, { |
| 307 | "bundle_id": bundle.bundle_id, |
| 308 | "judgments": [{"id": "n2", "name": "\U0001f525\U0001f525\U0001f525", |
| 309 | "junk": True}], |
| 310 | }) |
| 311 | judgments = handoff.read_judgments(path, bundle) |
| 312 | assert judgments["n2"].junk is True |
| 313 | # Emoji-only sanitizes to empty = per-row-absent name. |
| 314 | assert judgments["n2"].name is None |
| 315 | |
| 316 | |
| 317 | def test_junk_null_and_string_false_are_per_row_absent(tmp_path): |
| 318 | """Only real JSON booleans count as a junk verdict. ``"junk": null`` and |
| 319 | ``"junk": "false"`` are per-row-absent (fall back to the bundle |
| 320 | heuristic) - a truthy non-empty string must never read as junk=True.""" |
| 321 | bundle = _write(tmp_path) |
| 322 | path = _judgments_file(tmp_path, { |
| 323 | "bundle_id": bundle.bundle_id, |
| 324 | "judgments": [ |
| 325 | {"id": "n1", "junk": None, "worthiness": 60}, |
| 326 | {"id": "n2", "junk": "false", "worthiness": 40}, |
| 327 | ], |
| 328 | }) |
| 329 | judgments = handoff.read_judgments(path, bundle) |
| 330 | assert judgments["n1"].junk is None |
| 331 | assert judgments["n2"].junk is None |
| 332 | # The rest of each row still applies. |
| 333 | assert judgments["n1"].worthiness == 60 |
| 334 | assert judgments["n2"].worthiness == 40 |
| 335 | |
| 336 | |
| 337 | def test_bundle_reader_warns_and_keeps_valid_rows(tmp_path, capsys): |
| 338 | """Lenient per row: a non-object row and a row whose nested nomination |
| 339 | fails to construct are each warned (always visible) and skipped, while |
| 340 | every valid row still parses.""" |
| 341 | _write(tmp_path) |
| 342 | path = tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME |
| 343 | payload = json.loads(path.read_text(encoding="utf-8")) |
| 344 | payload["nominations"] = [ |
| 345 | "not an object", |
| 346 | {"id": "nbad", "nomination": {"worthiness": "not-a-number"}}, |
| 347 | *payload["nominations"], |
| 348 | ] |
| 349 | path.write_text(json.dumps(payload), encoding="utf-8") |
| 350 | read = handoff.read_nominations_bundle(config_dir=tmp_path) |
| 351 | assert [row.nomination_id for row in read.nominations] == ["n1", "n2"] |
| 352 | err = capsys.readouterr().err |
| 353 | assert "skipping malformed nomination row 1" in err |
| 354 | assert "skipping unparseable nomination row 2" in err |
| 355 | |
| 356 | |
| 357 | def test_judgments_reader_warns_on_non_object_and_blank_id_rows(tmp_path, capsys): |
| 358 | bundle = _write(tmp_path) |
| 359 | path = _judgments_file(tmp_path, { |
| 360 | "bundle_id": bundle.bundle_id, |
| 361 | "judgments": [ |
| 362 | "not an object", |
| 363 | {"name": "No Id Here", "worthiness": 90}, |
| 364 | {"id": " ", "worthiness": 90}, |
| 365 | {"id": "n1", "worthiness": 70}, |
| 366 | ], |
| 367 | }) |
| 368 | judgments = handoff.read_judgments(path, bundle) |
| 369 | assert set(judgments) == {"n1"} |
| 370 | assert judgments["n1"].worthiness == 70 |
| 371 | err = capsys.readouterr().err |
| 372 | assert "skipping malformed judgments row (not an object)" in err |
| 373 | assert err.count("skipping judgments row with no nomination id") == 2 |
| 374 | |
| 375 | |
| 376 | def test_angles_reader_warns_on_non_object_and_blank_id_rows(tmp_path, capsys): |
| 377 | bundle = _write(tmp_path) |
| 378 | path = tmp_path / "angles.json" |
| 379 | path.write_text(json.dumps({ |
| 380 | "bundle_id": bundle.bundle_id, |
| 381 | "angles": [ |
| 382 | "not an object", |
| 383 | {"podcast": "No id on this row"}, |
| 384 | {"id": "", "podcast": "Blank id"}, |
| 385 | {"id": "n1", "podcast": "A real hook about agent SDK churn"}, |
| 386 | ], |
| 387 | }), encoding="utf-8") |
| 388 | angles = handoff.read_angles(path, bundle) |
| 389 | assert set(angles) == {"n1"} |
| 390 | assert angles["n1"].podcast == "A real hook about agent SDK churn" |
| 391 | err = capsys.readouterr().err |
| 392 | assert "skipping malformed angles row (not an object)" in err |
| 393 | assert err.count("skipping angles row with no nomination id") == 2 |
| 394 | |
| 395 | |
| 396 | # --- Scenario 4: error matrix ------------------------------------------------- |
| 397 | |
| 398 | |
| 399 | def test_error_unreadable_bundle_file(tmp_path): |
| 400 | # A directory at the bundle path exists but cannot be read as a file. |
| 401 | (tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME).mkdir() |
| 402 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 403 | handoff.read_nominations_bundle(config_dir=tmp_path) |
| 404 | assert excinfo.value.message |
| 405 | |
| 406 | |
| 407 | def test_error_invalid_json(tmp_path): |
| 408 | (tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME).write_text( |
| 409 | "{not json", encoding="utf-8" |
| 410 | ) |
| 411 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 412 | handoff.read_nominations_bundle(config_dir=tmp_path) |
| 413 | assert "JSON" in excinfo.value.message |
| 414 | |
| 415 | |
| 416 | def test_error_top_level_non_dict(tmp_path): |
| 417 | (tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME).write_text( |
| 418 | "[]", encoding="utf-8" |
| 419 | ) |
| 420 | with pytest.raises(handoff.HandoffContractError): |
| 421 | handoff.read_nominations_bundle(config_dir=tmp_path) |
| 422 | |
| 423 | |
| 424 | def test_error_bundle_nominations_must_be_a_list(tmp_path): |
| 425 | """A dict (or anything non-list) under "nominations" is corrupt state: |
| 426 | fail closed with the re-sweep remedy, never an empty pool.""" |
| 427 | _write(tmp_path) |
| 428 | path = tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME |
| 429 | payload = json.loads(path.read_text(encoding="utf-8")) |
| 430 | payload["nominations"] = {"n1": {"id": "n1"}} |
| 431 | path.write_text(json.dumps(payload), encoding="utf-8") |
| 432 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 433 | handoff.read_nominations_bundle(config_dir=tmp_path) |
| 434 | message = excinfo.value.message |
| 435 | assert "nominations" in message |
| 436 | assert "--discover --nominate-only" in message |
| 437 | |
| 438 | |
| 439 | def test_error_bundle_all_rows_malformed_fails_closed(tmp_path): |
| 440 | """Leg 1 never writes an empty bundle (a zero-nomination sweep |
| 441 | short-circuits with no bundle file), so a non-empty nominations array |
| 442 | that parses to ZERO valid rows is corrupt state: HandoffContractError |
| 443 | with the re-sweep remedy, not a silent empty pool.""" |
| 444 | _write(tmp_path) |
| 445 | path = tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME |
| 446 | payload = json.loads(path.read_text(encoding="utf-8")) |
| 447 | payload["nominations"] = [ |
| 448 | "not an object", |
| 449 | {"id": "n1", "nomination": {"worthiness": "not-a-number"}}, |
| 450 | ] |
| 451 | path.write_text(json.dumps(payload), encoding="utf-8") |
| 452 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 453 | handoff.read_nominations_bundle(config_dir=tmp_path) |
| 454 | assert "--discover --nominate-only" in excinfo.value.message |
| 455 | |
| 456 | |
| 457 | def test_error_bundle_empty_nominations_list_fails_closed(tmp_path): |
| 458 | _write(tmp_path) |
| 459 | path = tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME |
| 460 | payload = json.loads(path.read_text(encoding="utf-8")) |
| 461 | payload["nominations"] = [] |
| 462 | path.write_text(json.dumps(payload), encoding="utf-8") |
| 463 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 464 | handoff.read_nominations_bundle(config_dir=tmp_path) |
| 465 | assert "--discover --nominate-only" in excinfo.value.message |
| 466 | |
| 467 | |
| 468 | @pytest.mark.skipif( |
| 469 | hasattr(os, "geteuid") and os.geteuid() == 0, |
| 470 | reason="root ignores directory permission bits", |
| 471 | ) |
| 472 | def test_write_bundle_unwritable_dir_is_contract_error_not_traceback(tmp_path): |
| 473 | """A locked/read-only/full state dir must be the protocol's clean exit-2 |
| 474 | path (HandoffContractError naming the path), never a raw OSError.""" |
| 475 | state_dir = tmp_path / "readonly" |
| 476 | state_dir.mkdir() |
| 477 | state_dir.chmod(0o500) |
| 478 | try: |
| 479 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 480 | _write(state_dir) |
| 481 | message = excinfo.value.message |
| 482 | assert str(state_dir / handoff.NOMINATIONS_BUNDLE_FILENAME) in message |
| 483 | assert "Permission denied" in message |
| 484 | finally: |
| 485 | state_dir.chmod(0o700) |
| 486 | |
| 487 | |
| 488 | def test_error_wrong_schema_version(tmp_path): |
| 489 | _write(tmp_path) |
| 490 | path = tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME |
| 491 | payload = json.loads(path.read_text(encoding="utf-8")) |
| 492 | payload["schema_version"] = "99.0" |
| 493 | path.write_text(json.dumps(payload), encoding="utf-8") |
| 494 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 495 | handoff.read_nominations_bundle(config_dir=tmp_path) |
| 496 | assert "99.0" in excinfo.value.message |
| 497 | |
| 498 | |
| 499 | def test_error_stale_ttl(tmp_path): |
| 500 | _write(tmp_path) |
| 501 | path = tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME |
| 502 | payload = json.loads(path.read_text(encoding="utf-8")) |
| 503 | stale = datetime.now(timezone.utc) - timedelta( |
| 504 | seconds=handoff.DISCOVERY_HANDOFF_TTL_SECONDS + 60 |
| 505 | ) |
| 506 | payload["generated_at"] = stale.isoformat() |
| 507 | path.write_text(json.dumps(payload), encoding="utf-8") |
| 508 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 509 | handoff.read_nominations_bundle(config_dir=tmp_path) |
| 510 | assert "--discover --nominate-only" in excinfo.value.message |
| 511 | |
| 512 | |
| 513 | def test_ttl_is_not_the_report_cache_env_knob(tmp_path, monkeypatch): |
| 514 | """A user who lowered LAST30DAYS_REPORT_CACHE_TTL_SECONDS for drill |
| 515 | freshness must not shrink the judgment-authoring window.""" |
| 516 | monkeypatch.setenv("LAST30DAYS_REPORT_CACHE_TTL_SECONDS", "1") |
| 517 | written = _write(tmp_path) |
| 518 | path = tmp_path / handoff.NOMINATIONS_BUNDLE_FILENAME |
| 519 | payload = json.loads(path.read_text(encoding="utf-8")) |
| 520 | two_minutes_old = datetime.now(timezone.utc) - timedelta(seconds=120) |
| 521 | payload["generated_at"] = two_minutes_old.isoformat() |
| 522 | path.write_text(json.dumps(payload), encoding="utf-8") |
| 523 | read = handoff.read_nominations_bundle(config_dir=tmp_path) |
| 524 | assert read.bundle_id == written.bundle_id |
| 525 | assert handoff.DISCOVERY_HANDOFF_TTL_SECONDS == 3600.0 |
| 526 | |
| 527 | |
| 528 | def test_error_bundle_not_found_with_save_dir_names_only_save_dir(tmp_path): |
| 529 | """An explicit save dir is the protocol's single handoff store: the |
| 530 | not-found error names ONLY the save-dir location, never the config dir.""" |
| 531 | save_dir = tmp_path / "saves" |
| 532 | config_dir = tmp_path / "config" |
| 533 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 534 | handoff.read_nominations_bundle(save_dir=save_dir, config_dir=config_dir) |
| 535 | message = excinfo.value.message |
| 536 | assert str(save_dir / handoff.NOMINATIONS_BUNDLE_FILENAME) in message |
| 537 | assert str(config_dir) not in message |
| 538 | assert "--discover --nominate-only" in message |
| 539 | assert message.rstrip().endswith("re-sweep.") |
| 540 | |
| 541 | |
| 542 | def test_error_bundle_not_found_without_save_dir_names_config_dir(tmp_path): |
| 543 | config_dir = tmp_path / "config" |
| 544 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 545 | handoff.read_nominations_bundle(save_dir=None, config_dir=config_dir) |
| 546 | message = excinfo.value.message |
| 547 | assert str(config_dir / handoff.NOMINATIONS_BUNDLE_FILENAME) in message |
| 548 | assert "--discover --nominate-only" in message |
| 549 | |
| 550 | |
| 551 | def test_explicit_save_dir_never_falls_back_to_config_bundle(tmp_path): |
| 552 | """SKILL.md contract: a different or missing save dir on a later leg |
| 553 | means the leg cannot find the handoff files. A fresh bundle in the |
| 554 | config dir must never silently satisfy a save-dir run (the same |
| 555 | scoping _scoped_store_db applies to research.db).""" |
| 556 | save_dir = tmp_path / "saves" |
| 557 | save_dir.mkdir() |
| 558 | config_dir = tmp_path / "config" |
| 559 | config_dir.mkdir() |
| 560 | _write(config_dir) # fresh, valid bundle in the config store |
| 561 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 562 | handoff.read_nominations_bundle(save_dir=save_dir, config_dir=config_dir) |
| 563 | message = excinfo.value.message |
| 564 | assert str(save_dir / handoff.NOMINATIONS_BUNDLE_FILENAME) in message |
| 565 | assert str(config_dir) not in message |
| 566 | |
| 567 | |
| 568 | def test_error_bundle_id_mismatch_names_locations_and_fix_id_remedy(tmp_path): |
| 569 | """A bundle_id MISMATCH means the host echoed the wrong id: the remedy is |
| 570 | to correct the bundle_id field and re-run this same leg - never the |
| 571 | expensive re-sweep/resume remedies (those belong to missing/stale |
| 572 | state).""" |
| 573 | save_dir = tmp_path / "saves" |
| 574 | config_dir = tmp_path / "config" |
| 575 | bundle = _write(config_dir) |
| 576 | path = _judgments_file(tmp_path, { |
| 577 | "bundle_id": "deadbeefdeadbeef", |
| 578 | "judgments": [], |
| 579 | }) |
| 580 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 581 | handoff.read_judgments(path, bundle, save_dir=save_dir, config_dir=config_dir) |
| 582 | message = excinfo.value.message |
| 583 | # Both ids and the searched location (save dir only: explicit save dir |
| 584 | # is the single handoff store) stay named. |
| 585 | assert "deadbeefdeadbeef" in message |
| 586 | assert bundle.bundle_id in message |
| 587 | assert str(save_dir / handoff.NOMINATIONS_BUNDLE_FILENAME) in message |
| 588 | assert str(config_dir) not in message |
| 589 | # The remedy is the cheap one: fix the id, re-run this leg. |
| 590 | assert "Correct the bundle_id field in your judgments file" in message |
| 591 | assert "re-run this same leg" in message |
| 592 | # The expensive-leg remedies must NOT appear on a mismatch. |
| 593 | assert "--discover --nominate-only" not in message |
| 594 | assert "--discover --judgments" not in message |
| 595 | assert "re-sweep" not in message |
| 596 | |
| 597 | |
| 598 | def test_error_unreadable_judgments_path(tmp_path): |
| 599 | bundle = _write(tmp_path) |
| 600 | with pytest.raises(handoff.HandoffContractError): |
| 601 | handoff.read_judgments(tmp_path / "missing.json", bundle) |
| 602 | |
| 603 | |
| 604 | def test_error_judgments_top_level_strict(tmp_path): |
| 605 | bundle = _write(tmp_path) |
| 606 | # Missing the "judgments" list entirely: strict at top level. |
| 607 | path = _judgments_file(tmp_path, {"bundle_id": bundle.bundle_id}) |
| 608 | with pytest.raises(handoff.HandoffContractError): |
| 609 | handoff.read_judgments(path, bundle) |
| 610 | # Top-level non-dict. |
| 611 | non_dict = tmp_path / "non-dict.json" |
| 612 | non_dict.write_text('["not", "a", "dict"]', encoding="utf-8") |
| 613 | with pytest.raises(handoff.HandoffContractError): |
| 614 | handoff.read_judgments(non_dict, bundle) |
| 615 | |
| 616 | |
| 617 | # --- Scenario 5: sanitation and collisions ------------------------------------ |
| 618 | |
| 619 | |
| 620 | def test_long_host_name_truncates_at_word_boundary(tmp_path): |
| 621 | bundle = _write(tmp_path) |
| 622 | long_name = " ".join(["momentum"] * 40) # well over 96 chars |
| 623 | path = _judgments_file(tmp_path, { |
| 624 | "bundle_id": bundle.bundle_id, |
| 625 | "judgments": [{"id": "n1", "name": long_name}], |
| 626 | }) |
| 627 | judgments = handoff.read_judgments(path, bundle) |
| 628 | name = judgments["n1"].name |
| 629 | assert name is not None |
| 630 | assert len(name) <= 96 |
| 631 | # Cut at a word boundary: no partial trailing token. |
| 632 | assert set(name.split()) == {"momentum"} |
| 633 | |
| 634 | |
| 635 | def test_case_only_name_collisions_disambiguate_not_collapse(): |
| 636 | first = _nomination( |
| 637 | "Agent Wars", |
| 638 | [_item("hn1", "hackernews", |
| 639 | "Agent Wars heat up as Anthropic ships Claude runtime", |
| 640 | engagement={"points": 900, "comments": 100})], |
| 641 | ) |
| 642 | second = _nomination( |
| 643 | "Agent Runtime Rivalry", |
| 644 | [_item("rd1", "reddit", |
| 645 | "Agent wars escalate as OpenAI counters with Codex swarm", |
| 646 | engagement={"score": 250, "num_comments": 30})], |
| 647 | ) |
| 648 | resolved = handoff.resolve_name_collisions([ |
| 649 | (first, "Agent Wars"), |
| 650 | (second, "agent wars"), |
| 651 | ]) |
| 652 | assert len(resolved) == 2 # never collapses distinct nominations |
| 653 | assert resolved[0] == "Agent Wars" |
| 654 | assert resolved[1].casefold() != "agent wars" |
| 655 | assert resolved[1].casefold().startswith("agent wars") |
| 656 | assert len({name.casefold() for name in resolved}) == 2 |
| 657 | |
| 658 | |
| 659 | def test_indistinguishable_collision_still_never_drops(): |
| 660 | shared = [_item("hn1", "hackernews", "Agent Wars heat up", |
| 661 | engagement={"points": 100})] |
| 662 | first = _nomination("Agent Wars", shared) |
| 663 | second = _nomination("Agent Wars redux", shared) |
| 664 | resolved = handoff.resolve_name_collisions([ |
| 665 | (first, "Agent Wars"), |
| 666 | (second, "agent wars"), |
| 667 | ]) |
| 668 | assert len(resolved) == 2 |
| 669 | assert len({name.casefold() for name in resolved}) == 2 |
| 670 | |
| 671 | |
| 672 | # --- Scenario 6: angles reader ------------------------------------------------- |
| 673 | |
| 674 | |
| 675 | def test_angles_apply_truncate_and_none_path_returns_empty(tmp_path): |
| 676 | bundle = _write(tmp_path) |
| 677 | # Missing angles file is legal. |
| 678 | assert handoff.read_angles(None, bundle) == {} |
| 679 | long_angle = " ".join(["angle"] * 60) # well over 200 chars |
| 680 | path = tmp_path / "angles.json" |
| 681 | path.write_text(json.dumps({ |
| 682 | "bundle_id": bundle.bundle_id, |
| 683 | "angles": [ |
| 684 | {"id": "n1", |
| 685 | "podcast": "Why the agent SDK churn is a tax on small teams", |
| 686 | "x_article": long_angle}, |
| 687 | {"id": "n9", "podcast": "Ghost angle"}, |
| 688 | ], |
| 689 | }), encoding="utf-8") |
| 690 | angles = handoff.read_angles(path, bundle) |
| 691 | assert angles["n1"].podcast == ( |
| 692 | "Why the agent SDK churn is a tax on small teams" |
| 693 | ) |
| 694 | x_article = angles["n1"].x_article |
| 695 | assert x_article is not None |
| 696 | assert len(x_article) <= 200 |
| 697 | assert set(x_article.split()) == {"angle"} # word-boundary truncation |
| 698 | assert "n9" not in angles # unknown ids ignored |
| 699 | |
| 700 | |
| 701 | # --- Scenario 7: digest ---------------------------------------------------------- |
| 702 | |
| 703 | |
| 704 | LONG_TITLE = ( |
| 705 | "Anthropic ships a Claude agent runtime and the fallout reshapes agents " * 4 |
| 706 | ).strip() # > 220 chars |
| 707 | |
| 708 | |
| 709 | def test_digest_names_bundle_path_instruction_and_capped_evidence(tmp_path): |
| 710 | long_snippet = ( |
| 711 | "The community reaction spans pricing, lock-in, and migration pain. " * 10 |
| 712 | ).strip() # > 420 chars |
| 713 | nomination = _nomination( |
| 714 | "Agent Runtime Fallout", |
| 715 | [_item( |
| 716 | "hn1", "hackernews", LONG_TITLE, |
| 717 | engagement={"points": 1200, "comments": 300}, |
| 718 | snippet=long_snippet, |
| 719 | metadata={"top_comments": [{ |
| 720 | "excerpt": "This will consolidate the whole agent ecosystem " |
| 721 | "within a year", |
| 722 | "score": 1635, |
| 723 | "author": "dev_a", |
| 724 | }]}, |
| 725 | )], |
| 726 | seed_score=77.7, |
| 727 | ) |
| 728 | entries = [_entry(nomination, cluster_id="c-fallout"), _pool()[1]] |
| 729 | bundle = _write(tmp_path, entries) |
| 730 | digest = handoff.build_host_digest(bundle) |
| 731 | # (b) names the bundle file path and instructs reading it before judging. |
| 732 | assert str(bundle.path) in digest |
| 733 | assert "before judging" in digest |
| 734 | # (a) one structural line per nomination, keyed by nomination id. |
| 735 | lines = digest.splitlines() |
| 736 | n1_lines = [line for line in lines if line.startswith("n1 | ")] |
| 737 | n2_lines = [line for line in lines if line.startswith("n2 | ")] |
| 738 | assert len(n1_lines) == 1 |
| 739 | assert len(n2_lines) == 1 |
| 740 | # Structural line carries id/sources/signal only - the third-party title |
| 741 | # lives inside the untrusted-content fence, never on the structural line. |
| 742 | assert "hackernews" in n1_lines[0] # seed source names |
| 743 | assert "1,500 native interactions" in n1_lines[0] # engagement signal |
| 744 | assert LONG_TITLE[:40] not in n1_lines[0] |
| 745 | # Evidence caps: the old judge surface (title ~220, snippet ~420). |
| 746 | assert LONG_TITLE[:220] in digest |
| 747 | assert LONG_TITLE[:230] not in digest |
| 748 | assert long_snippet[:420] in digest |
| 749 | assert long_snippet[:430] not in digest |
| 750 | assert "consolidate the whole agent ecosystem" in digest # top comment |
| 751 | # Plain text: no markdown tables. |
| 752 | assert not any(line.lstrip().startswith("|") for line in lines) |
| 753 | |
| 754 | |
| 755 | def test_digest_fences_untrusted_evidence_like_the_engine_judge(tmp_path): |
| 756 | """F12: titles/snippets/comments are scraped third-party data. The digest |
| 757 | wraps them in the same fence the rerank judge uses (security-notice |
| 758 | header + <untrusted_content> tags); the structural surfaces (nomination |
| 759 | id/sources/signal lines, bundle path, judging instructions) stay outside |
| 760 | the fence.""" |
| 761 | bundle = _write(tmp_path) |
| 762 | digest = handoff.build_host_digest(bundle) |
| 763 | # The exact rerank fence: notice header and tags, reused not re-invented. |
| 764 | assert rerank.UNTRUSTED_CONTENT_NOTICE in digest |
| 765 | fence_open = digest.index("<untrusted_content>") |
| 766 | fence_close = digest.index("</untrusted_content>") |
| 767 | assert fence_open < fence_close |
| 768 | # Every evidence surface (leader title, leader snippet, top community |
| 769 | # comment) sits inside the fence. |
| 770 | leader_title = ( |
| 771 | "Agent SDK Wars heat up as Anthropic ships a Claude agent runtime" |
| 772 | ) |
| 773 | assert fence_open < digest.index(leader_title) < fence_close |
| 774 | assert fence_open < digest.index(f"Evidence about {leader_title}") < fence_close |
| 775 | assert fence_open < digest.index( |
| 776 | "The SDK churn is unsustainable for small teams" |
| 777 | ) < fence_close |
| 778 | # Structural surfaces stay outside (before) the fence. |
| 779 | assert digest.index(str(bundle.path)) < fence_open |
| 780 | assert digest.index("before judging") < fence_open |
| 781 | assert digest.index("n1 | ") < fence_open |
| 782 | assert digest.index("n2 | ") < fence_open |
| 783 | |
| 784 | |
| 785 | # --- U5: pending-report reader (leg 3) ---------------------------------------- |
| 786 | |
| 787 | |
| 788 | def _pending_payload(**overrides) -> dict: |
| 789 | payload = { |
| 790 | "kind": schema.DISCOVERY_PENDING_KIND, |
| 791 | "schema_version": schema.DISCOVERY_PENDING_SCHEMA_VERSION, |
| 792 | "bundle_id": "cafe1234cafe1234", |
| 793 | "generated_at": datetime.now(timezone.utc).isoformat(), |
| 794 | "run_ref": "discover:AI agents:2026-07-21T00:00:00+00:00", |
| 795 | "report": {"domain": "AI agents", "topics": []}, |
| 796 | "angle_inputs": { |
| 797 | "n1": { |
| 798 | "name": "Agent SDK Wars", |
| 799 | "titles": "t1; t2", |
| 800 | "top_comment": "", |
| 801 | "engagement": "1,500 native interactions across hackernews", |
| 802 | }, |
| 803 | }, |
| 804 | } |
| 805 | payload.update(overrides) |
| 806 | return payload |
| 807 | |
| 808 | |
| 809 | def _write_pending(state_dir, **overrides) -> dict: |
| 810 | state_dir.mkdir(parents=True, exist_ok=True) |
| 811 | payload = _pending_payload(**overrides) |
| 812 | (state_dir / handoff.PENDING_REPORT_FILENAME).write_text( |
| 813 | json.dumps(payload), encoding="utf-8" |
| 814 | ) |
| 815 | return payload |
| 816 | |
| 817 | |
| 818 | def test_pending_report_round_trip(tmp_path): |
| 819 | payload = _write_pending(tmp_path) |
| 820 | pending = handoff.read_pending_report(save_dir=None, config_dir=tmp_path) |
| 821 | assert pending.bundle_id == payload["bundle_id"] |
| 822 | assert pending.schema_version == schema.DISCOVERY_PENDING_SCHEMA_VERSION |
| 823 | assert pending.generated_at == payload["generated_at"] |
| 824 | assert pending.run_ref == payload["run_ref"] |
| 825 | assert pending.report == payload["report"] |
| 826 | assert pending.angle_inputs == payload["angle_inputs"] |
| 827 | assert pending.path == tmp_path / handoff.PENDING_REPORT_FILENAME |
| 828 | |
| 829 | |
| 830 | def test_pending_report_parses_mock_flag_defaulting_false(tmp_path): |
| 831 | """F19: the pending reader restores the leg-2 mock provenance; files |
| 832 | written before the flag existed read as real (mock=False).""" |
| 833 | _write_pending(tmp_path, mock=True) |
| 834 | assert handoff.read_pending_report(config_dir=tmp_path).mock is True |
| 835 | _write_pending(tmp_path) # no "mock" key at all |
| 836 | assert handoff.read_pending_report(config_dir=tmp_path).mock is False |
| 837 | |
| 838 | |
| 839 | def test_pending_report_save_dir_takes_precedence(tmp_path): |
| 840 | save_dir = tmp_path / "saves" |
| 841 | config_dir = tmp_path / "config" |
| 842 | _write_pending(save_dir, bundle_id="fromsavedir00001") |
| 843 | _write_pending(config_dir, bundle_id="fromconfigdir001") |
| 844 | pending = handoff.read_pending_report(save_dir=save_dir, config_dir=config_dir) |
| 845 | assert pending.bundle_id == "fromsavedir00001" |
| 846 | |
| 847 | |
| 848 | def test_pending_report_not_found_with_save_dir_names_only_save_dir(tmp_path): |
| 849 | save_dir = tmp_path / "saves" |
| 850 | config_dir = tmp_path / "config" |
| 851 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 852 | handoff.read_pending_report(save_dir=save_dir, config_dir=config_dir) |
| 853 | message = excinfo.value.message |
| 854 | assert str(save_dir / handoff.PENDING_REPORT_FILENAME) in message |
| 855 | assert str(config_dir) not in message |
| 856 | # Remedy: re-run the resume leg, or the full protocol when the bundle is |
| 857 | # stale too. |
| 858 | assert "--discover --judgments" in message |
| 859 | assert "--discover --nominate-only" in message |
| 860 | |
| 861 | |
| 862 | def test_pending_report_not_found_without_save_dir_names_config_dir(tmp_path): |
| 863 | config_dir = tmp_path / "config" |
| 864 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 865 | handoff.read_pending_report(save_dir=None, config_dir=config_dir) |
| 866 | message = excinfo.value.message |
| 867 | assert str(config_dir / handoff.PENDING_REPORT_FILENAME) in message |
| 868 | assert "--discover --judgments" in message |
| 869 | |
| 870 | |
| 871 | def test_explicit_save_dir_never_falls_back_to_config_pending(tmp_path): |
| 872 | """The mandated F2 pin: explicit save dir + missing pending file there + |
| 873 | a FRESH pending report in the config dir = HandoffContractError naming |
| 874 | only the save-dir location. No silent cross-store load.""" |
| 875 | save_dir = tmp_path / "saves" |
| 876 | save_dir.mkdir() |
| 877 | config_dir = tmp_path / "config" |
| 878 | _write_pending(config_dir) # fresh, valid pending report in config store |
| 879 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 880 | handoff.read_pending_report(save_dir=save_dir, config_dir=config_dir) |
| 881 | message = excinfo.value.message |
| 882 | assert str(save_dir / handoff.PENDING_REPORT_FILENAME) in message |
| 883 | assert str(config_dir) not in message |
| 884 | |
| 885 | |
| 886 | def test_pending_report_invalid_json(tmp_path): |
| 887 | (tmp_path / handoff.PENDING_REPORT_FILENAME).write_text( |
| 888 | "{not json", encoding="utf-8" |
| 889 | ) |
| 890 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 891 | handoff.read_pending_report(config_dir=tmp_path) |
| 892 | assert "JSON" in excinfo.value.message |
| 893 | |
| 894 | |
| 895 | def test_pending_report_top_level_non_dict(tmp_path): |
| 896 | (tmp_path / handoff.PENDING_REPORT_FILENAME).write_text("[]", encoding="utf-8") |
| 897 | with pytest.raises(handoff.HandoffContractError): |
| 898 | handoff.read_pending_report(config_dir=tmp_path) |
| 899 | |
| 900 | |
| 901 | def test_pending_report_wrong_kind(tmp_path): |
| 902 | _write_pending(tmp_path, kind="discovery-nominations") |
| 903 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 904 | handoff.read_pending_report(config_dir=tmp_path) |
| 905 | assert "discovery-nominations" in excinfo.value.message |
| 906 | |
| 907 | |
| 908 | def test_pending_report_wrong_schema_version(tmp_path): |
| 909 | _write_pending(tmp_path, schema_version="99.0") |
| 910 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 911 | handoff.read_pending_report(config_dir=tmp_path) |
| 912 | assert "99.0" in excinfo.value.message |
| 913 | |
| 914 | |
| 915 | def test_pending_report_missing_bundle_id(tmp_path): |
| 916 | _write_pending(tmp_path, bundle_id="") |
| 917 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 918 | handoff.read_pending_report(config_dir=tmp_path) |
| 919 | assert "bundle_id" in excinfo.value.message |
| 920 | |
| 921 | |
| 922 | def test_pending_report_stale_ttl_measured_from_resume_write(tmp_path): |
| 923 | """The leg-2 write started a FRESH TTL window: staleness is measured from |
| 924 | the pending report's own generated_at, never the leg-1 sweep's.""" |
| 925 | stale = datetime.now(timezone.utc) - timedelta( |
| 926 | seconds=handoff.DISCOVERY_HANDOFF_TTL_SECONDS + 60 |
| 927 | ) |
| 928 | _write_pending(tmp_path, generated_at=stale.isoformat()) |
| 929 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 930 | handoff.read_pending_report(config_dir=tmp_path) |
| 931 | message = excinfo.value.message |
| 932 | assert "stale" in message |
| 933 | assert "--discover --judgments" in message |
| 934 | |
| 935 | |
| 936 | def test_pending_report_non_dict_report_rejected(tmp_path): |
| 937 | _write_pending(tmp_path, report=["not", "a", "dict"]) |
| 938 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 939 | handoff.read_pending_report(config_dir=tmp_path) |
| 940 | assert "report" in excinfo.value.message |
| 941 | |
| 942 | |
| 943 | def test_angles_bind_against_pending_report(tmp_path): |
| 944 | """Leg 3 reads angles against the PENDING report: the bundle_id echo |
| 945 | validates against it, and known ids are the surviving angle_inputs ids.""" |
| 946 | _write_pending(tmp_path) |
| 947 | pending = handoff.read_pending_report(config_dir=tmp_path) |
| 948 | path = tmp_path / "angles.json" |
| 949 | path.write_text(json.dumps({ |
| 950 | "bundle_id": pending.bundle_id, |
| 951 | "angles": [ |
| 952 | {"id": "n1", "podcast": "Why the SDK churn taxes small teams"}, |
| 953 | {"id": "n2", "podcast": "Ghost angle for a floored nomination"}, |
| 954 | ], |
| 955 | }), encoding="utf-8") |
| 956 | angles = handoff.read_angles(path, pending) |
| 957 | assert angles["n1"].podcast == "Why the SDK churn taxes small teams" |
| 958 | # n2 did not survive the floor (absent from angle_inputs): ignored. |
| 959 | assert "n2" not in angles |
| 960 | |
| 961 | |
| 962 | def test_angles_bundle_id_mismatch_against_pending_report(tmp_path): |
| 963 | _write_pending(tmp_path) |
| 964 | pending = handoff.read_pending_report(config_dir=tmp_path) |
| 965 | path = tmp_path / "angles.json" |
| 966 | path.write_text(json.dumps({ |
| 967 | "bundle_id": "deadbeefdeadbeef", |
| 968 | "angles": [{"id": "n1", "podcast": "Bound to the wrong bundle"}], |
| 969 | }), encoding="utf-8") |
| 970 | with pytest.raises(handoff.HandoffContractError) as excinfo: |
| 971 | handoff.read_angles(path, pending, save_dir=None, config_dir=tmp_path) |
| 972 | message = excinfo.value.message |
| 973 | assert "deadbeefdeadbeef" in message |
| 974 | assert pending.bundle_id in message |
| 975 | # The finalize leg validates against the PENDING report - the mismatch |
| 976 | # message must point the host's retry at discover-pending.json, never at |
| 977 | # the nominations bundle (regression: the binding error used to name the |
| 978 | # wrong file on this leg). |
| 979 | assert "pending discovery report" in message |
| 980 | assert "Pending-report locations searched" in message |
| 981 | assert handoff.PENDING_REPORT_FILENAME in message |
| 982 | assert handoff.NOMINATIONS_BUNDLE_FILENAME not in message |
| 983 | # A MISMATCH is a wrong echoed id: the remedy is to fix the id and re-run |
| 984 | # this same leg - the expensive re-sweep/resume remedies must not appear. |
| 985 | assert "Correct the bundle_id field in your angles file" in message |
| 986 | assert "re-run this same leg" in message |
| 987 | assert "--discover --judgments" not in message |
| 988 | assert "--discover --nominate-only" not in message |
| 989 | |
| 990 | |
| 991 | # --- Hygiene --------------------------------------------------------------------- |
| 992 | |
| 993 | |
| 994 | def test_handoff_module_does_not_reference_the_engine_judge(): |
| 995 | """The legacy engine-judge module is deleted (U6); the handoff module |
| 996 | ports its sanitizers and must never reference the module by name.""" |
| 997 | source = inspect.getsource(handoff) |
| 998 | needle = "discovery" + "_judge" # split so this pin never matches itself |
| 999 | assert needle not in source |
| 1000 |