返回 last30days-skill
test_discover_handoff.py
根目录 / tests / test_discover_handoff.py
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
1000 lines PYTHON