返回 last30days-skill
test_discover_mode.py
根目录 / tests / test_discover_mode.py
1 import argparse
2 import contextlib
3 import inspect
4 import json
5 import os
6 import subprocess
7 import sys
8 import urllib.error
9 from pathlib import Path
10 from unittest import mock
11
12 import pytest
13
14 import last30days as cli
15 from lib import dates, discovery_handoff, pipeline, planner, reddit_listing, render, rerank, schema
16
17
18 REPO_ROOT = Path(__file__).resolve().parents[1]
19
20
21 def _item(
22 item_id: str,
23 source: str,
24 title: str,
25 *,
26 published_at: str = "2026-07-09",
27 engagement: dict[str, int | float] | None = None,
28 ) -> schema.SourceItem:
29 return schema.SourceItem(
30 item_id=item_id,
31 source=source,
32 title=title,
33 body=title,
34 url=f"https://{source}.example/{item_id}",
35 published_at=published_at,
36 engagement=engagement or {},
37 snippet=f"Evidence about {title}",
38 )
39
40
41 def _candidate(item: schema.SourceItem) -> schema.Candidate:
42 return schema.Candidate(
43 candidate_id=f"candidate-{item.item_id}",
44 item_id=item.item_id,
45 source=item.source,
46 title=item.title,
47 url=item.url,
48 snippet=item.snippet,
49 subquery_labels=["discovery-listings"],
50 native_ranks={f"discovery-listings:{item.source}": 1},
51 local_relevance=0.9,
52 freshness=95,
53 engagement=100,
54 source_quality=0.8,
55 rrf_score=0.1,
56 sources=[item.source],
57 source_items=[item],
58 final_score=80,
59 )
60
61
62 def test_discovery_plan_reuses_category_peer_mapping():
63 plan = planner.build_discovery_plan(
64 "AI agents",
65 available_sources=["reddit", "hackernews"],
66 )
67
68 assert plan.category == "ai_agent_framework"
69 assert plan.subreddits == ["LangChain", "LocalLLaMA", "AI_Agents", "MachineLearning"]
70 assert plan.sources == ["reddit", "hackernews"]
71
72
73 def test_discovery_plan_keeps_keyless_reddit_for_unknown_domains():
74 plan = planner.build_discovery_plan(
75 "urban gardening",
76 available_sources=["reddit", "hackernews"],
77 )
78
79 assert plan.category is None
80 assert plan.subreddits == ["all"]
81 assert plan.sources == ["reddit", "hackernews"]
82
83
84 def test_discovery_plan_empty_domain_is_global_trending():
85 """Bare --discover: sweep every river feed's hot list; X sits out of the
86 nominate stage because its search lane needs a keyword."""
87 plan = planner.build_discovery_plan(
88 "",
89 available_sources=["reddit", "hackernews", "digg", "x"],
90 )
91
92 assert plan.domain == ""
93 assert plan.category is None
94 assert plan.subreddits == ["all"]
95 assert plan.sources == ["reddit", "hackernews", "digg"]
96 assert "x" not in plan.sources
97
98
99 def test_global_discovery_disables_keyword_gate():
100 """Global trending fetches with keyword_gate=False; domain runs keep it on."""
101 seen: dict[str, bool] = {}
102
103 def fake_fetch(source, plan, *, from_date, to_date, depth, mock, config, keyword_gate=True):
104 seen[plan.domain or "global"] = keyword_gate
105 return [], None
106
107 with mock.patch.object(pipeline, "available_sources", return_value=["hackernews"]), \
108 mock.patch.object(pipeline, "_fetch_discovery_source", side_effect=fake_fetch):
109 pipeline.run_discover(domain="", config={}, as_of_date="2026-07-10")
110 pipeline.run_discover(domain="AI agents", config={}, as_of_date="2026-07-10")
111
112 assert seen["global"] is False
113 assert seen["AI agents"] is True
114
115
116 def test_uncategorized_discovery_uses_parseable_r_all_listing_paths():
117 card = (
118 '<shreddit-post permalink="/r/gardening/comments/abc123/urban_garden/" '
119 'post-title="Urban gardening is taking off" score="42" comment-count="7" '
120 'author="gardener" subreddit-name="gardening" '
121 'created-timestamp="2026-07-09T12:00:00+00:00">'
122 )
123 requested_urls: list[str] = []
124
125 def fake_get(url, **_kwargs):
126 requested_urls.append(url)
127 return card
128
129 with mock.patch.object(reddit_listing.http, "reddit_keyless_get_text", side_effect=fake_get):
130 result = reddit_listing.fetch_discovery_listings(
131 ["all"], query="urban gardening",
132 )
133
134 assert len(result["items"]) == 1
135 assert any("/r/all/rising/" in url for url in requested_urls)
136 assert any("/r/all/top/?t=week" in url for url in requested_urls)
137 assert all("name=all" not in url for url in requested_urls)
138
139
140 def test_velocity_scoring_favors_a_recent_spike_over_static_bigness():
141 recent = _item(
142 "recent",
143 "reddit",
144 "Recent spike",
145 published_at="2026-07-09",
146 engagement={"score": 100, "num_comments": 10},
147 )
148 old = _item(
149 "old",
150 "reddit",
151 "Older large thread",
152 published_at="2026-06-20",
153 engagement={"score": 300, "num_comments": 10},
154 )
155
156 assert rerank.engagement_velocity_score(recent, as_of_date="2026-07-10") > (
157 rerank.engagement_velocity_score(old, as_of_date="2026-07-10")
158 )
159
160
161 def test_domain_filter_ignores_generic_ai_only_matches():
162 assert pipeline._matches_discovery_domain(
163 "AI agents", "An AI agent bankrupted its operator"
164 )
165 assert not pipeline._matches_discovery_domain(
166 "AI agents", "Global dialogue on AI governance"
167 )
168
169
170 @pytest.mark.parametrize(
171 ("domain", "listing_title"),
172 [
173 ("城市园艺", "城市园艺技巧与社区花园"),
174 ("גינון עירוני", "מדריך חדש לגינון עירוני"),
175 ],
176 )
177 def test_domain_filter_tokenizes_non_latin_domains(domain, listing_title):
178 assert pipeline._matches_discovery_domain(domain, listing_title)
179
180
181 def test_x_velocity_excludes_views_and_bookmarks():
182 xquik_item = _item(
183 "xquik",
184 "x",
185 "X backend reach",
186 engagement={
187 "likes": 10,
188 "reposts": 3,
189 "replies": 2,
190 "quotes": 1,
191 "views": 100_000,
192 "bookmarks": 5_000,
193 },
194 )
195 standard_item = _item(
196 "standard",
197 "x",
198 "X backend interactions",
199 engagement={"likes": 10, "reposts": 3, "replies": 2, "quotes": 1},
200 )
201
202 assert rerank.discovery_engagement_total(xquik_item) == 16
203 assert rerank.engagement_velocity_score(
204 xquik_item, as_of_date="2026-07-10"
205 ) == rerank.engagement_velocity_score(standard_item, as_of_date="2026-07-10")
206
207
208 def test_discovery_renderer_snapshot():
209 report = schema.DiscoveryReport(
210 domain="AI agents",
211 range_from="2026-06-10",
212 range_to="2026-07-10",
213 generated_at="2026-07-10T00:00:00+00:00",
214 plan=schema.DiscoveryPlan(
215 domain="AI agents",
216 category="ai_agent_framework",
217 subreddits=["AI_Agents"],
218 sources=["reddit", "hackernews"],
219 ),
220 topics=[schema.DiscoveryTopic(
221 rank=1,
222 name="Agent memory protocols",
223 why_spiking="Two independent listing items accelerated this week.",
224 momentum="new-this-week",
225 velocity_score=123.45,
226 sources=["hackernews", "reddit"],
227 engagement_by_source={
228 "reddit": {"score": 120, "num_comments": 30},
229 "hackernews": {"points": 80},
230 },
231 command='/last30days "Agent memory protocols"',
232 )],
233 )
234
235 with mock.patch.object(render, "_render_badge", return_value=["BADGE", ""]):
236 rendered = render.render_discovery(report)
237
238 assert rendered == (
239 "BADGE\n\n"
240 "# Trending discovery: AI agents\n\n"
241 "Window: 2026-06-10 to 2026-07-10\n"
242 "Feeds: reddit, hackernews\n"
243 "Communities: r/AI_Agents\n\n"
244 "## 1. Agent memory protocols\n\n"
245 "**Momentum:** New this week · velocity 123.45\n\n"
246 "Two independent listing items accelerated this week.\n\n"
247 "**Evidence:** Reddit: score 120, num comments 30 · Hacker News: points 80\n\n"
248 "**Research next:** `/last30days \"Agent memory protocols\"`\n"
249 )
250
251
252 def test_keyless_discovery_degrades_without_digg():
253 def fake_fetch(source, plan, *, from_date, to_date, depth, mock, config, keyword_gate=True):
254 return pipeline._mock_discovery_items(source, plan.domain, to_date), None
255
256 with mock.patch.object(pipeline, "available_sources", return_value=["reddit", "hackernews"]), \
257 mock.patch.object(pipeline, "_fetch_discovery_source", side_effect=fake_fetch):
258 report = pipeline.run_discover(
259 domain="AI agents",
260 config={},
261 as_of_date="2026-07-10",
262 )
263
264 assert 5 <= len(report.topics) <= 10
265 assert report.source_status["reddit"].state == "ok"
266 assert report.source_status["hackernews"].state == "ok"
267 assert report.source_status["digg"].state == "skipped-unconfigured"
268 assert report.source_status["x"].state == "skipped-unconfigured"
269 assert all(topic.command.startswith('/last30days "') for topic in report.topics)
270
271
272 def test_discovery_drops_zero_velocity_clusters():
273 raw_item = {
274 "id": "zero-engagement",
275 "text": "AI agent launch with no interactions",
276 "url": "https://x.com/example/status/1",
277 "author_handle": "example",
278 "date": "2026-07-09",
279 "engagement": {"likes": 0, "reposts": 0, "replies": 0, "quotes": 0},
280 "relevance": 0.9,
281 }
282
283 with mock.patch.object(pipeline, "available_sources", return_value=["x"]), \
284 mock.patch.object(pipeline, "_fetch_discovery_source", return_value=([raw_item], None)):
285 report = pipeline.run_discover(
286 domain="AI agents",
287 config={},
288 as_of_date="2026-07-10",
289 )
290
291 assert report.topics == []
292 assert report.outcome == "nothing-solid"
293 assert any("confidence floor" in warning for warning in report.warnings)
294
295
296 def test_explicit_unavailable_discovery_source_does_not_widen_to_other_sources():
297 with mock.patch.object(pipeline, "available_sources", return_value=[]), \
298 mock.patch.object(pipeline, "_fetch_discovery_source") as fetch:
299 with pytest.raises(ValueError, match="No listing sources are available"):
300 pipeline.run_discover(
301 domain="AI agents",
302 config={},
303 requested_sources=["digg"],
304 as_of_date="2026-07-10",
305 )
306
307 fetch.assert_not_called()
308
309
310 def test_discovery_reads_browser_credentials_and_does_not_schedule_pending_x():
311 parser = cli.build_parser()
312 args, extra = parser.parse_known_args(["--discover", "AI agents"])
313 assert cli._config_policy_for_args(args, "", extra).browser_cookies == "read"
314
315 no_cookies_args, extra = parser.parse_known_args(
316 ["--no-browser-cookies", "--discover", "AI agents"]
317 )
318 assert cli._config_policy_for_args(no_cookies_args, "", extra).browser_cookies == "off"
319
320 fetched_sources: list[str] = []
321
322 def fake_available_sources(config, requested_sources, *, x_pending=None, local_only=False):
323 assert x_pending is False
324 return ["reddit", "hackernews"] + (["x"] if x_pending is not False else [])
325
326 def fake_fetch(source, plan, *, from_date, to_date, depth, mock, config, keyword_gate=True):
327 fetched_sources.append(source)
328 return pipeline._mock_discovery_items(source, plan.domain, to_date), None
329
330 with mock.patch.object(pipeline, "available_sources", side_effect=fake_available_sources), \
331 mock.patch.object(pipeline, "_fetch_discovery_source", side_effect=fake_fetch):
332 report = pipeline.run_discover(
333 domain="AI agents",
334 config={"FROM_BROWSER": "firefox", "_BROWSER_COOKIE_MODE": "plan_only"},
335 as_of_date="2026-07-10",
336 )
337
338 assert "x" not in fetched_sources
339 assert report.source_status["x"].state == "skipped-unconfigured"
340
341
342 def test_authenticated_x_discovery_uses_available_backend():
343 plan = planner.build_discovery_plan(
344 "AI agents",
345 available_sources=["x"],
346 )
347 raw = pipeline._mock_discovery_items("x", plan.domain, "2026-07-10")
348 with mock.patch.object(pipeline.env, "x_backend_chain", return_value=["bird"]), \
349 mock.patch.object(pipeline, "_fetch_x_backend", return_value=(raw, "")) as fetch:
350 items, error = pipeline._fetch_discovery_source(
351 "x",
352 plan,
353 from_date="2026-06-10",
354 to_date="2026-07-10",
355 depth="default",
356 mock=False,
357 config={"AUTH_TOKEN": "dummy", "CT0": "dummy"},
358 )
359
360 assert error is None
361 assert len(items) == 6
362 fetch.assert_called_once()
363
364
365 def test_listing_failure_is_not_reported_as_clean_no_results():
366 def fake_fetch(source, plan, *, from_date, to_date, depth, mock, config, keyword_gate=True):
367 if source == "reddit":
368 return [], "connection timed out"
369 return pipeline._mock_discovery_items(source, plan.domain, to_date), None
370
371 with mock.patch.object(pipeline, "available_sources", return_value=["reddit", "hackernews"]), \
372 mock.patch.object(pipeline, "_fetch_discovery_source", side_effect=fake_fetch):
373 report = pipeline.run_discover(
374 domain="AI agents",
375 config={},
376 as_of_date="2026-07-10",
377 )
378
379 assert report.source_status["reddit"].state == "timeout"
380 assert report.source_status["reddit"].detail == "connection timed out"
381
382
383 def test_discovery_listing_block_is_reported_as_rate_limited():
384 # get_text swallows the 429 and hands back None; without the tee the lane
385 # could not tell that apart from an empty listing and the sweep reported a
386 # clean no-results (issue #899).
387 blocked = urllib.error.HTTPError(
388 "https://www.reddit.com/svc/shreddit/community-more-posts/rising/",
389 429,
390 "Too Many Requests",
391 {},
392 None,
393 )
394
395 with mock.patch.object(pipeline, "available_sources", return_value=["reddit"]), \
396 mock.patch("lib.http.time.sleep"), \
397 mock.patch("lib.http.urllib.request.urlopen", side_effect=blocked):
398 report = pipeline.run_discover(
399 domain="AI agents",
400 config={},
401 as_of_date="2026-07-10",
402 subreddits=["AI_Agents"],
403 )
404
405 outcome = report.source_status["reddit"]
406 assert outcome.state == "rate-limited"
407 assert "429" in (outcome.detail or "")
408
409
410 def test_reddit_discovery_adapter_preserves_partial_feed_errors():
411 item = {
412 "url": "https://reddit.com/r/example/comments/1",
413 "title": "AI agent launch",
414 }
415 with mock.patch.object(
416 reddit_listing,
417 "_fetch_one_with_status",
418 side_effect=[([], "rising timed out"), ([item], None)],
419 ):
420 result = reddit_listing.fetch_discovery_listings(
421 ["AI_Agents"], query="AI agents",
422 )
423
424 assert result["items"] == [item]
425 assert result["errors"] == ["r/AI_Agents rising: rising timed out"]
426
427
428 def test_discovery_cli_json_contract_and_mutual_exclusion():
429 result = subprocess.run(
430 [
431 sys.executable,
432 "skills/last30days/scripts/last30days.py",
433 "--discover",
434 "AI agents",
435 "--mock",
436 "--emit=json",
437 ],
438 cwd=REPO_ROOT,
439 capture_output=True,
440 text=True,
441 check=False,
442 )
443 assert result.returncode == 0, result.stderr
444 payload = json.loads(result.stdout)
445 assert payload["schema_version"] == "1.1"
446 assert payload["kind"] == "discovery"
447 assert 5 <= len(payload["results"]) <= 10
448 assert payload["results"][0]["command"].startswith('/last30days "')
449 # 1.1 fields ship in every result, with defaults when nothing set them.
450 for topic in payload["results"]:
451 assert topic["podcast_angle"] is None
452 assert topic["x_article_angle"] is None
453 assert topic["previously_surfaced_count"] == 0
454 assert topic["last_surfaced"] is None
455 assert topic["covered"] is False
456
457 invalid = subprocess.run(
458 [
459 sys.executable,
460 "skills/last30days/scripts/last30days.py",
461 "topic",
462 "--discover",
463 "AI agents",
464 "--mock",
465 ],
466 cwd=REPO_ROOT,
467 capture_output=True,
468 text=True,
469 check=False,
470 )
471 assert invalid.returncode == 2
472 assert "cannot be combined with a positional topic" in invalid.stderr
473
474 drill_conflict = subprocess.run(
475 [
476 sys.executable,
477 "skills/last30days/scripts/last30days.py",
478 "--discover",
479 "AI agents",
480 "--drill",
481 "1",
482 "--mock",
483 ],
484 cwd=REPO_ROOT,
485 capture_output=True,
486 text=True,
487 check=False,
488 )
489 assert drill_conflict.returncode == 2
490 assert "mutually exclusive" in drill_conflict.stderr
491
492
493 def _discovery_report(topic: schema.DiscoveryTopic) -> schema.DiscoveryReport:
494 return schema.DiscoveryReport(
495 domain="AI agents",
496 range_from="2026-06-10",
497 range_to="2026-07-10",
498 generated_at="2026-07-10T00:00:00+00:00",
499 plan=schema.DiscoveryPlan(
500 domain="AI agents",
501 category="ai_agent_framework",
502 subreddits=["AI_Agents"],
503 sources=["reddit", "hackernews"],
504 ),
505 topics=[topic],
506 )
507
508
509 def test_discovery_export_round_trips_angles_and_queue_annotations():
510 """The 1.1 fields must carry real values through to_discovery_export."""
511 payload = schema.to_discovery_export(_discovery_report(schema.DiscoveryTopic(
512 rank=1,
513 name="Agent memory protocols",
514 why_spiking="Two independent listing items accelerated this week.",
515 momentum="new-this-week",
516 velocity_score=123.45,
517 sources=["hackernews", "reddit"],
518 engagement_by_source={"reddit": {"score": 120, "num_comments": 30}},
519 command='/last30days "Agent memory protocols"',
520 podcast_angle="Why agent memory is the next context-window fight",
521 x_article_angle="Agent memory protocols, explained through this week's launches",
522 previously_surfaced_count=2,
523 last_surfaced="2026-07-03",
524 covered=True,
525 )))
526
527 assert payload["schema_version"] == "1.1"
528 result = payload["results"][0]
529 assert result["podcast_angle"] == "Why agent memory is the next context-window fight"
530 assert result["x_article_angle"] == (
531 "Agent memory protocols, explained through this week's launches"
532 )
533 assert result["previously_surfaced_count"] == 2
534 assert result["last_surfaced"] == "2026-07-03"
535 assert result["covered"] is True
536
537
538 def test_discovery_topic_constructs_with_only_pre_existing_fields():
539 """Pre-1.1 constructor calls must keep working; new fields default."""
540 topic = schema.DiscoveryTopic(
541 rank=1,
542 name="Agent memory protocols",
543 why_spiking="Two independent listing items accelerated this week.",
544 momentum="building",
545 velocity_score=10.0,
546 sources=["reddit"],
547 engagement_by_source={"reddit": {"score": 120}},
548 command='/last30days "Agent memory protocols"',
549 )
550
551 assert topic.podcast_angle is None
552 assert topic.x_article_angle is None
553 assert topic.previously_surfaced_count == 0
554 assert topic.last_surfaced is None
555 assert topic.covered is False
556
557 result = schema.to_discovery_export(_discovery_report(topic))["results"][0]
558 assert result["podcast_angle"] is None
559 assert result["x_article_angle"] is None
560 assert result["previously_surfaced_count"] == 0
561 assert result["last_surfaced"] is None
562 assert result["covered"] is False
563
564
565 def test_discovery_cli_mock_render_has_no_angle_or_pipeline_lines():
566 """--mock runs never resolve a reasoning provider, so rendered cards must
567 omit the U5 angle and Pipeline lines entirely - and stay deterministic
568 across runs (same-day mock fixtures)."""
569 def _run_once() -> subprocess.CompletedProcess:
570 return subprocess.run(
571 [
572 sys.executable,
573 "skills/last30days/scripts/last30days.py",
574 "--discover",
575 "AI agents",
576 "--mock",
577 ],
578 cwd=REPO_ROOT,
579 capture_output=True,
580 text=True,
581 check=False,
582 )
583
584 first = _run_once()
585 second = _run_once()
586 assert first.returncode == 0, first.stderr
587 assert "**Podcast angle:**" not in first.stdout
588 assert "**X article angle:**" not in first.stdout
589 assert "**Pipeline:**" not in first.stdout
590 assert first.stdout == second.stdout
591
592
593 def test_discovery_cli_bare_discover_is_global_trending():
594 """Bare --discover (no domain) must run global trending, not error."""
595 result = subprocess.run(
596 [
597 sys.executable,
598 "skills/last30days/scripts/last30days.py",
599 "--discover",
600 "--mock",
601 "--emit=json",
602 ],
603 cwd=REPO_ROOT,
604 capture_output=True,
605 text=True,
606 check=False,
607 )
608 assert result.returncode == 0, result.stderr
609 payload = json.loads(result.stdout)
610 assert payload["kind"] == "discovery"
611 assert payload["domain"] == ""
612 assert payload["outcome"] in {"ok", "nothing-solid"}
613
614
615 def test_discovery_cli_shallow_skips_enrichment():
616 """--discover-shallow ranks on listing evidence only (still floored)."""
617 result = subprocess.run(
618 [
619 sys.executable,
620 "skills/last30days/scripts/last30days.py",
621 "--discover", "AI agents",
622 "--discover-shallow",
623 "--mock",
624 "--emit=json",
625 ],
626 cwd=REPO_ROOT,
627 capture_output=True,
628 text=True,
629 check=False,
630 )
631 assert result.returncode == 0, result.stderr
632 payload = json.loads(result.stdout)
633 assert payload["results"], "shallow mock sweep should still rank mock topics"
634 assert all(
635 "listing item" in topic["why_spiking"] for topic in payload["results"]
636 ), "shallow mode must be judged on listing evidence, not enriched corpora"
637
638
639 def test_discovery_cli_rejects_shallow_without_discover():
640 """--discover-shallow on a normal topic run must error, not silently no-op
641 into a full research pass (P2 from PR #816 review)."""
642 result = subprocess.run(
643 [
644 sys.executable,
645 "skills/last30days/scripts/last30days.py",
646 "AI agents",
647 "--discover-shallow",
648 "--mock",
649 ],
650 cwd=REPO_ROOT,
651 capture_output=True,
652 text=True,
653 check=False,
654 )
655 assert result.returncode == 2
656 assert "--discover-shallow only applies to --discover runs" in result.stderr
657
658
659 def test_discovery_cli_rejects_historical_as_of():
660 result = subprocess.run(
661 [
662 sys.executable,
663 "skills/last30days/scripts/last30days.py",
664 "--discover",
665 "AI agents",
666 "--as-of",
667 "2026-06-01",
668 "--mock",
669 ],
670 cwd=REPO_ROOT,
671 capture_output=True,
672 text=True,
673 check=False,
674 )
675
676 assert result.returncode == 2
677 assert "--as-of cannot be used with --discover" in result.stderr
678 assert "current live listings" in result.stderr
679
680
681 def test_discovery_filters_incompatible_default_sources_but_rejects_explicit_only():
682 default_result = subprocess.run(
683 [
684 sys.executable,
685 "skills/last30days/scripts/last30days.py",
686 "--discover",
687 "AI agents",
688 "--mock",
689 "--emit=json",
690 ],
691 cwd=REPO_ROOT,
692 env={**os.environ, "LAST30DAYS_DEFAULT_SEARCH": "reddit,x,youtube,hn"},
693 capture_output=True,
694 text=True,
695 check=False,
696 )
697 assert default_result.returncode == 0, default_result.stderr
698
699 explicit_result = subprocess.run(
700 [
701 sys.executable,
702 "skills/last30days/scripts/last30days.py",
703 "--discover",
704 "AI agents",
705 "--search=youtube",
706 "--mock",
707 ],
708 cwd=REPO_ROOT,
709 capture_output=True,
710 text=True,
711 check=False,
712 )
713 assert explicit_result.returncode == 2
714 assert "unsupported: youtube" in explicit_result.stderr
715
716
717 def test_detect_category_rejects_suffix_false_positives():
718 from lib import categories
719
720 assert categories.detect_category("Dubai agents") is None
721 assert categories.detect_category("Thai agents real estate") is None
722 assert categories.detect_category("AI agents") == "ai_agent_framework"
723 assert categories.detect_category("what's new in ai agent frameworks") == "ai_agent_framework"
724
725
726 def test_discovery_engagement_excludes_rank_metadata():
727 from lib import pipeline, schema
728
729 items = [
730 schema.SourceItem(
731 item_id=f"digg-{i}", source="digg", title="t", body="b",
732 url=f"https://di.gg/{i}", published_at="2026-07-05", snippet="s",
733 engagement={"postCount": 5, "rank": 100 * (i + 1), "rank_score": 0.5},
734 )
735 for i in range(3)
736 ]
737 totals = pipeline._discovery_engagement(items)
738 assert totals["digg"]["postCount"] == 15
739 assert "rank" not in totals["digg"]
740 assert "rank_score" not in totals["digg"]
741
742
743 def test_domain_matching_preserves_non_plural_anchors():
744 from lib import pipeline
745
746 assert pipeline._matches_discovery_domain("AI bias", "Addressing AI bias in models")
747 assert pipeline._matches_discovery_domain("supply chain crisis", "The crisis deepens for chip supply")
748 # Plural matching still works both directions.
749 assert pipeline._matches_discovery_domain("AI agents", "The best AI agent stacks")
750
751
752 def test_x_fallback_success_is_clean(monkeypatch):
753 from lib import pipeline, env
754
755 calls = []
756
757 def fake_fetch(backend, subquery, from_date, to_date, depth, config):
758 calls.append(backend)
759 if backend == "bird":
760 return [], "cookie expired"
761 return [object()], None
762
763 monkeypatch.setattr(pipeline, "_fetch_x_backend", fake_fetch)
764 monkeypatch.setattr(env, "x_backend_chain", lambda config: ["bird", "xquik"])
765 plan = pipeline.schema.DiscoveryPlan(
766 domain="ai agents", category=None, subreddits=[], sources=["x"],
767 )
768 items, error = pipeline._fetch_discovery_source(
769 "x", plan,
770 from_date="2026-06-11", to_date="2026-07-11", depth="quick",
771 mock=False, config={},
772 )
773 assert error is None
774 assert len(items) == 1
775 assert calls == ["bird", "xquik"]
776
777
778 def _digg_envelope(*clusters: dict) -> dict:
779 return {"results": list(clusters)}
780
781
782 def _digg_cluster(cluster_id: str, title: str, tldr: str = "") -> dict:
783 return {
784 "clusterUrlId": cluster_id,
785 "title": title,
786 "tldr": tldr,
787 "rank": 5,
788 "postCount": 12,
789 "uniqueAuthors": 8,
790 }
791
792
793 def test_digg_discovery_drops_off_domain_clusters(monkeypatch):
794 """Regression: a crypto sweep surfaced AI stories because the Digg
795 branch (an AI-only leaderboard feed) applied no domain filter."""
796 envelope = _digg_envelope(
797 _digg_cluster("c1", "Bitcoin crypto rally accelerates"),
798 _digg_cluster("c2", "OpenAI ships a new frontier model"),
799 )
800 monkeypatch.setattr(pipeline.digg, "search_digg", lambda *a, **k: envelope)
801 plan = schema.DiscoveryPlan(
802 domain="crypto", category=None, subreddits=[], sources=["digg"],
803 )
804 items, error = pipeline._fetch_discovery_source(
805 "digg", plan,
806 from_date="2026-06-11", to_date="2026-07-11", depth="quick",
807 mock=False, config={},
808 )
809 assert error is None
810 titles = [item["title"] for item in items]
811 assert titles == ["Bitcoin crypto rally accelerates"]
812
813
814 def test_digg_discovery_keeps_domain_matching_clusters(monkeypatch):
815 envelope = _digg_envelope(
816 _digg_cluster("c1", "AI agents reshape support desks"),
817 _digg_cluster("c2", "The best AI agent stacks compared"),
818 )
819 monkeypatch.setattr(pipeline.digg, "search_digg", lambda *a, **k: envelope)
820 plan = schema.DiscoveryPlan(
821 domain="AI agents", category=None, subreddits=[], sources=["digg"],
822 )
823 items, error = pipeline._fetch_discovery_source(
824 "digg", plan,
825 from_date="2026-06-11", to_date="2026-07-11", depth="quick",
826 mock=False, config={},
827 )
828 assert error is None
829 assert len(items) == 2
830
831
832 def test_digg_discovery_all_filtered_is_clean_no_results(monkeypatch):
833 envelope = _digg_envelope(
834 _digg_cluster("c1", "OpenAI ships a new frontier model"),
835 _digg_cluster("c2", "Anthropic updates its agent SDK"),
836 )
837 monkeypatch.setattr(pipeline.digg, "search_digg", lambda *a, **k: envelope)
838 plan = schema.DiscoveryPlan(
839 domain="crypto", category=None, subreddits=[], sources=["digg"],
840 )
841 items, error = pipeline._fetch_discovery_source(
842 "digg", plan,
843 from_date="2026-06-11", to_date="2026-07-11", depth="quick",
844 mock=False, config={},
845 )
846 assert error is None
847 assert items == []
848
849
850 def test_x_discovery_preserves_producing_backends_own_error(monkeypatch):
851 """A backend that returns items plus its own error is a partial outcome;
852 only earlier failed-over backends' errors are observability-only."""
853 monkeypatch.setattr(
854 pipeline, "_fetch_x_backend",
855 lambda *a, **k: ([{"id": "x-1", "title": "t"}], "rate limited after page 1"),
856 )
857 monkeypatch.setattr(pipeline.env, "x_backend_chain", lambda config: ["bird"])
858 plan = schema.DiscoveryPlan(
859 domain="ai agents", category=None, subreddits=[], sources=["x"],
860 )
861 items, error = pipeline._fetch_discovery_source(
862 "x", plan,
863 from_date="2026-06-11", to_date="2026-07-11", depth="quick",
864 mock=False, config={},
865 )
866 assert len(items) == 1
867 assert error == "rate limited after page 1"
868
869
870 # --- U6 persistent topic queue: discovery persistence hook + queue CLI -------
871
872
873 def _queue_topic(rank: int, name: str) -> schema.DiscoveryTopic:
874 return schema.DiscoveryTopic(
875 rank=rank,
876 name=name,
877 why_spiking=f"Listing evidence about {name}.",
878 momentum="building",
879 velocity_score=42.5,
880 sources=["reddit"],
881 engagement_by_source={"reddit": {"score": 120}},
882 command=f'/last30days "{name}"',
883 )
884
885
886 def _queue_report(names: list[str]) -> schema.DiscoveryReport:
887 return schema.DiscoveryReport(
888 domain="AI agents",
889 range_from="2026-06-20",
890 range_to="2026-07-20",
891 generated_at="2026-07-20T00:00:00+00:00",
892 plan=schema.DiscoveryPlan(
893 domain="AI agents", category=None, subreddits=["all"],
894 sources=["reddit"],
895 ),
896 topics=[_queue_topic(rank, name) for rank, name in enumerate(names, start=1)],
897 )
898
899
900 def _run_scoped_discover(save_dir, config=None, names=("Gemma 4 chat templates",)):
901 import datetime as _datetime
902
903 parser = cli.build_parser()
904 args, _extra = parser.parse_known_args(
905 ["--discover", "AI agents", "--save-dir", str(save_dir), "--save-suffix", os.urandom(4).hex()]
906 )
907 report = _queue_report(list(names))
908 # Stamp a real, distinct run identity per mocked run: generated_at is
909 # runtime-stamped in reality, and a fixed fixture timestamp would make two
910 # "separate" runs share a run_ref and trip the retry idempotency guard.
911 report.generated_at = _datetime.datetime.now(_datetime.timezone.utc).isoformat()
912 with mock.patch.object(pipeline, "run_discover", return_value=report):
913 return cli._run_discover(args, dict(config or {}))
914
915
916 def test_discovery_run_records_surfacings_in_scoped_db_only(tmp_path, monkeypatch, capsys):
917 import store
918
919 monkeypatch.setattr(store, "DB_PATH", tmp_path / "global" / "research.db")
920 save_dir = tmp_path / "client"
921 save_dir.mkdir()
922
923 assert _run_scoped_discover(save_dir) == 0
924 first = capsys.readouterr().out
925 assert "**Pipeline:**" not in first # nothing prior to annotate from
926
927 scoped_db = save_dir / "research.db"
928 assert scoped_db.is_file()
929 assert not (tmp_path / "global" / "research.db").exists()
930
931 import sqlite3
932 conn = sqlite3.connect(scoped_db)
933 rows = conn.execute(
934 "SELECT name, surface_count, status FROM discovery_topics"
935 ).fetchall()
936 conn.close()
937 assert rows == [("Gemma 4 chat templates", 1, "surfaced")]
938
939
940 def test_second_discovery_run_annotates_from_prior_state_then_records(tmp_path, capsys):
941 save_dir = tmp_path / "client"
942 save_dir.mkdir()
943
944 assert _run_scoped_discover(save_dir) == 0
945 capsys.readouterr()
946 assert _run_scoped_discover(save_dir) == 0
947 second = capsys.readouterr().out
948
949 # Annotation reflects the state BEFORE this run's own surfacing was
950 # recorded: one prior surfacing means this appearance is the 2nd.
951 assert "**Pipeline:** surfaced 2nd time" in second
952
953 import sqlite3
954 conn = sqlite3.connect(save_dir / "research.db")
955 count = conn.execute(
956 "SELECT surface_count FROM discovery_topics WHERE name = ?",
957 ("Gemma 4 chat templates",),
958 ).fetchone()[0]
959 conn.close()
960 assert count == 2
961
962
963 def test_covered_topic_resurfacing_renders_marked_covered(tmp_path, capsys):
964 import store
965
966 save_dir = tmp_path / "client"
967 save_dir.mkdir()
968
969 assert _run_scoped_discover(save_dir) == 0
970 with store.scoped_db(save_dir / "research.db"):
971 assert store.mark_discovery_covered(
972 "Gemma 4 chat templates", as_of="2026-07-20"
973 ) is not None
974 capsys.readouterr()
975
976 assert _run_scoped_discover(save_dir) == 0
977 rendered = capsys.readouterr().out
978 assert "marked covered" in rendered
979 assert "**Pipeline:** surfaced 2nd time, marked covered" in rendered
980
981
982 def test_queue_opt_out_via_process_env_seam(tmp_path, monkeypatch, capsys):
983 from lib import env
984
985 monkeypatch.setenv("LAST30DAYS_DISCOVERY_QUEUE", "off")
986 monkeypatch.setattr(env, "CONFIG_FILE", tmp_path / "does-not-exist.env")
987 monkeypatch.chdir(tmp_path)
988 with mock.patch.object(env, "_load_keychain", return_value={}), \
989 mock.patch.object(env, "_load_pass", return_value={}):
990 config = env.get_config()
991 assert config["LAST30DAYS_DISCOVERY_QUEUE"] == "off"
992
993 save_dir = tmp_path / "client"
994 save_dir.mkdir()
995 assert _run_scoped_discover(save_dir, config=config) == 0
996 assert "**Pipeline:**" not in capsys.readouterr().out
997 assert not (save_dir / "research.db").exists()
998
999
1000 def test_queue_opt_out_via_env_file_seam(tmp_path, monkeypatch, capsys):
1001 from lib import env
1002
1003 monkeypatch.delenv("LAST30DAYS_DISCOVERY_QUEUE", raising=False)
1004 env_file = tmp_path / "config.env"
1005 env_file.write_text("LAST30DAYS_DISCOVERY_QUEUE=off\n", encoding="utf-8")
1006 monkeypatch.setattr(env, "CONFIG_FILE", env_file)
1007 monkeypatch.chdir(tmp_path)
1008 with mock.patch.object(env, "_load_keychain", return_value={}), \
1009 mock.patch.object(env, "_load_pass", return_value={}):
1010 config = env.get_config()
1011 assert config["LAST30DAYS_DISCOVERY_QUEUE"] == "off"
1012
1013 save_dir = tmp_path / "client"
1014 save_dir.mkdir()
1015 assert _run_scoped_discover(save_dir, config=config) == 0
1016 assert not (save_dir / "research.db").exists()
1017
1018
1019 def test_discovery_queue_failure_never_crashes_a_finished_run(tmp_path, monkeypatch, capsys):
1020 """P0: a broken research.db (locked, read-only, corrupt) must not destroy
1021 a finished multi-minute pipeline run - the brief still renders (exit 0)
1022 with a stderr warning, and queue fields keep their defaults."""
1023 import sqlite3
1024
1025 import store
1026
1027 def _locked(*_args, **_kwargs):
1028 raise sqlite3.OperationalError("database is locked")
1029
1030 monkeypatch.setattr(store, "record_discovery_surfacing", _locked)
1031 save_dir = tmp_path / "client"
1032 save_dir.mkdir()
1033
1034 assert _run_scoped_discover(save_dir) == 0
1035 captured = capsys.readouterr()
1036 assert "## 1. Gemma 4 chat templates" in captured.out
1037 assert "**Pipeline:**" not in captured.out
1038 assert "[last30days] Warning:" in captured.err
1039 assert "database is locked" in captured.err
1040
1041
1042 def test_sibling_topics_in_same_run_do_not_cross_annotate(tmp_path, capsys):
1043 """Annotations describe the queue state BEFORE this run: two same-anchor
1044 siblings surfaced by ONE run must not fuzzy-match each other's rows and
1045 render a false 'surfaced 2nd time' on first-ever topics."""
1046 import sqlite3
1047
1048 save_dir = tmp_path / "client"
1049 save_dir.mkdir()
1050
1051 assert _run_scoped_discover(
1052 save_dir, names=("Gemma 4 chat templates", "Gemma 4 enterprise")
1053 ) == 0
1054 rendered = capsys.readouterr().out
1055 assert "## 1. Gemma 4 chat templates" in rendered
1056 assert "## 2. Gemma 4 enterprise" in rendered
1057 assert "**Pipeline:**" not in rendered
1058
1059 conn = sqlite3.connect(save_dir / "research.db")
1060 rows = conn.execute(
1061 "SELECT name, surface_count FROM discovery_topics ORDER BY name"
1062 ).fetchall()
1063 conn.close()
1064 assert rows == [("Gemma 4 chat templates", 1), ("Gemma 4 enterprise", 1)]
1065
1066
1067 def test_discovery_mock_run_writes_no_research_db(tmp_path):
1068 """--mock stays 100% side-effect-free: no queue writes, no research.db."""
1069 result = subprocess.run(
1070 [
1071 sys.executable,
1072 "skills/last30days/scripts/last30days.py",
1073 "--discover",
1074 "AI agents",
1075 "--mock",
1076 "--save-dir",
1077 str(tmp_path),
1078 ],
1079 cwd=REPO_ROOT,
1080 capture_output=True,
1081 text=True,
1082 check=False,
1083 )
1084 assert result.returncode == 0, result.stderr
1085 assert not (tmp_path / "research.db").exists()
1086
1087
1088 def test_queue_list_shows_uncovered_only_by_default(tmp_path, monkeypatch, capsys):
1089 import store
1090
1091 save_dir = tmp_path / "client"
1092 save_dir.mkdir()
1093 with store.scoped_db(save_dir / "research.db"):
1094 store.record_discovery_surfacing(
1095 "Gemma 4 chat templates", domain="AI agents", run_ref="r1", as_of="2026-07-19",
1096 )
1097 store.record_discovery_surfacing(
1098 "OpenAI Agent SDK", domain="AI agents", run_ref="r1", as_of="2026-07-20",
1099 )
1100 store.mark_discovery_covered("OpenAI Agent SDK", as_of="2026-07-20")
1101
1102 monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {})
1103 monkeypatch.setattr(
1104 sys, "argv", ["last30days.py", "queue", "list", "--save-dir", str(save_dir)]
1105 )
1106 assert cli.main() == 0
1107 out = capsys.readouterr().out
1108 assert "Gemma 4 chat templates" in out
1109 assert "OpenAI Agent SDK" not in out
1110 for column in ("name", "domain", "surface_count", "last_surfaced", "status"):
1111 assert column in out
1112
1113
1114 def test_queue_list_empty_db_reports_no_recorded_runs(tmp_path, monkeypatch, capsys):
1115 """A db that exists but has zero discovery rows (e.g. created via --store
1116 by a topic run) must not claim every topic is covered."""
1117 import store
1118
1119 save_dir = tmp_path / "client"
1120 save_dir.mkdir()
1121 store.init_db(save_dir / "research.db")
1122
1123 monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {})
1124 monkeypatch.setattr(
1125 sys, "argv", ["last30days.py", "queue", "list", "--save-dir", str(save_dir)]
1126 )
1127 assert cli.main() == 0
1128 out = capsys.readouterr().out
1129 assert "no discovery run has recorded topics yet" in out
1130 assert "marked covered" not in out
1131
1132
1133 def test_queue_cover_marks_topic_covered(tmp_path, monkeypatch, capsys):
1134 import sqlite3
1135
1136 import store
1137
1138 save_dir = tmp_path / "client"
1139 save_dir.mkdir()
1140 with store.scoped_db(save_dir / "research.db"):
1141 store.record_discovery_surfacing(
1142 "Gemma 4 chat templates", domain="AI agents", run_ref="r1", as_of="2026-07-19",
1143 )
1144
1145 monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {})
1146 monkeypatch.setattr(
1147 sys,
1148 "argv",
1149 ["last30days.py", "queue", "cover", "Gemma 4 chat templates", "--save-dir", str(save_dir)],
1150 )
1151 assert cli.main() == 0
1152
1153 conn = sqlite3.connect(save_dir / "research.db")
1154 status, covered_at = conn.execute(
1155 "SELECT status, covered_at FROM discovery_topics WHERE name = ?",
1156 ("Gemma 4 chat templates",),
1157 ).fetchone()
1158 conn.close()
1159 assert status == "covered"
1160 assert covered_at
1161
1162
1163 def test_queue_cover_unknown_name_exits_2_with_stderr(tmp_path, monkeypatch, capsys):
1164 import store
1165
1166 save_dir = tmp_path / "client"
1167 save_dir.mkdir()
1168 with store.scoped_db(save_dir / "research.db"):
1169 store.record_discovery_surfacing(
1170 "Gemma 4 chat templates", domain="AI agents", run_ref="r1", as_of="2026-07-19",
1171 )
1172
1173 monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {})
1174 monkeypatch.setattr(
1175 sys,
1176 "argv",
1177 ["last30days.py", "queue", "cover", "No Such Topic", "--save-dir", str(save_dir)],
1178 )
1179 assert cli.main() == 2
1180 err = capsys.readouterr().err
1181 assert "No Such Topic" in err
1182
1183
1184 def test_queue_cover_cli_unknown_name_subprocess_exit_code(tmp_path):
1185 result = subprocess.run(
1186 [
1187 sys.executable,
1188 "skills/last30days/scripts/last30days.py",
1189 "queue",
1190 "cover",
1191 "No Such Topic",
1192 "--save-dir",
1193 str(tmp_path),
1194 ],
1195 cwd=REPO_ROOT,
1196 capture_output=True,
1197 text=True,
1198 check=False,
1199 )
1200 assert result.returncode == 2
1201 assert "No Such Topic" in result.stderr
1202
1203
1204 def test_discovery_exits_when_configured_sources_have_no_discovery_feed(monkeypatch, capsys):
1205 """A configured source boundary must hold: never silently widen a sweep
1206 to feeds the user filtered out."""
1207 monkeypatch.setattr(
1208 cli.env, "get_config", lambda **_kwargs: {"LAST30DAYS_DEFAULT_SEARCH": "youtube"}
1209 )
1210 monkeypatch.setattr(sys, "argv", ["last30days.py", "--discover", "AI agents", "--mock"])
1211 with mock.patch.object(pipeline, "run_discover") as run:
1212 assert cli.main() == 2
1213
1214 run.assert_not_called()
1215 err = capsys.readouterr().err
1216 assert "no discovery-capable sources" in err
1217 assert "reddit" in err
1218
1219
1220 # --- U2: three-command protocol CLI surface (flags, scoping, dispatch) ---
1221
1222
1223 def _run_protocol_cli(argv: list[str], env_overrides: dict[str, str] | None = None):
1224 """Run the real CLI entry point; env overrides layer onto the test env."""
1225 return subprocess.run(
1226 [sys.executable, "skills/last30days/scripts/last30days.py", *argv],
1227 cwd=REPO_ROOT,
1228 capture_output=True,
1229 text=True,
1230 check=False,
1231 env={**os.environ, **(env_overrides or {})},
1232 )
1233
1234
1235 @pytest.mark.parametrize(
1236 ("argv", "fragment"),
1237 [
1238 # Orphans: protocol flags without --discover reject loudly (never a
1239 # silent no-op into a normal research run - same rule as
1240 # --discover-shallow).
1241 (
1242 ["AI agents", "--nominate-only", "--mock"],
1243 "--nominate-only only applies to --discover runs",
1244 ),
1245 (
1246 ["AI agents", "--judgments", "judgments.json", "--mock"],
1247 "--judgments only applies to --discover runs",
1248 ),
1249 (
1250 ["AI agents", "--finalize", "--mock"],
1251 "--finalize only applies to --discover runs",
1252 ),
1253 (
1254 ["AI agents", "--angles", "angles.json", "--mock"],
1255 "--angles only applies to --discover --finalize runs",
1256 ),
1257 # --angles is a --finalize modifier even when --discover is present.
1258 (
1259 ["--discover", "AI agents", "--angles", "angles.json", "--mock"],
1260 "--angles only applies to --discover --finalize runs",
1261 ),
1262 # The three legs are mutually exclusive: one leg per invocation.
1263 (
1264 ["--discover", "AI agents", "--nominate-only", "--judgments", "j.json"],
1265 "--nominate-only and --judgments are mutually exclusive",
1266 ),
1267 (
1268 ["--discover", "AI agents", "--nominate-only", "--finalize"],
1269 "--nominate-only and --finalize are mutually exclusive",
1270 ),
1271 (
1272 ["--discover", "AI agents", "--judgments", "j.json", "--finalize"],
1273 "--judgments and --finalize are mutually exclusive",
1274 ),
1275 ],
1276 )
1277 def test_discovery_cli_protocol_flag_combinations_exit_2(argv, fragment):
1278 """Every orphan/mutual-exclusion combination names the offending flags."""
1279 result = _run_protocol_cli(argv)
1280 assert result.returncode == 2, result.stderr
1281 assert fragment in result.stderr
1282
1283
1284 @pytest.mark.parametrize(
1285 "leg_argv",
1286 [
1287 ["--nominate-only"],
1288 ["--judgments", "judgments.json"],
1289 ["--finalize"],
1290 ],
1291 )
1292 def test_discovery_cli_mock_protocol_leg_requires_save_dir(leg_argv):
1293 """--mock protocol legs without --save-dir would write handoff state into
1294 the real config dir; reject before any leg runs. LAST30DAYS_MEMORY_DIR is
1295 pinned empty so a dev machine's save-dir fallback can't mask the check."""
1296 result = _run_protocol_cli(
1297 ["--discover", "AI agents", "--mock", *leg_argv],
1298 env_overrides={"LAST30DAYS_MEMORY_DIR": ""},
1299 )
1300 assert result.returncode == 2, result.stderr
1301 assert "mock protocol legs require --save-dir to stay side-effect-free" in result.stderr
1302
1303
1304 def test_discovery_cli_finalize_flag_reaches_leg_3(tmp_path):
1305 """With --save-dir, --finalize routes to the real leg 3 (formerly the U5
1306 stub): an empty state dir is a contract failure naming the pending report
1307 and the resume-leg remedy - proof the dispatch reached the finalize body."""
1308 result = _run_protocol_cli(
1309 ["--discover", "AI agents", "--mock", "--save-dir", str(tmp_path), "--finalize"],
1310 )
1311 assert result.returncode == 2, result.stderr
1312 assert "No pending discovery report found" in result.stderr
1313 assert str(tmp_path / discovery_handoff.PENDING_REPORT_FILENAME) in result.stderr
1314 assert "--discover --judgments" in result.stderr
1315
1316
1317 # --- U3 leg 1: --discover --nominate-only (sweep, bundle, digest) -------------
1318
1319
1320 def _run_nominate_only(save_dir, *extra_argv):
1321 return _run_protocol_cli(
1322 [
1323 "--discover", "AI agents", "--mock",
1324 "--save-dir", str(save_dir), "--nominate-only", *extra_argv,
1325 ],
1326 # Pin the default-search seam empty so a dev machine's configured
1327 # boundary cannot leak into the bundle-context assertions.
1328 env_overrides={"LAST30DAYS_DEFAULT_SEARCH": ""},
1329 )
1330
1331
1332 def test_discovery_cli_nominate_only_writes_bundle_and_digest(tmp_path):
1333 """Leg 1 exits 0, writes the nominations bundle under the save dir, and
1334 prints the host digest naming the bundle path with one line per
1335 nomination - no judging, enrichment, floor, or queue on this leg."""
1336 result = _run_nominate_only(tmp_path)
1337 assert result.returncode == 0, result.stderr
1338 bundle_path = tmp_path / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME
1339 assert bundle_path.is_file()
1340 payload = json.loads(bundle_path.read_text(encoding="utf-8"))
1341 assert payload["kind"] == schema.DISCOVERY_NOMINATIONS_KIND
1342 assert payload["schema_version"] == schema.DISCOVERY_NOMINATIONS_SCHEMA_VERSION
1343 assert payload["domain"] == "AI agents"
1344 assert payload["tier"] == "deep"
1345 # Leg-1 invocation context rides along for leg 2.
1346 assert payload["context"]["lookback_days"] == 30
1347 assert payload["context"]["requested_sources"] is None
1348 assert payload["context"]["enrichment_source_boundary"] is None
1349 # Momentum window matches the sweep dates (computed the same day).
1350 assert (payload["from_date"], payload["to_date"]) == dates.get_date_range(30)
1351 assert payload["nominations"]
1352 for row in payload["nominations"]:
1353 # Heuristic fallbacks: no provider ran, so the nomination's own
1354 # name/junk ARE the topic_shape heuristics.
1355 assert row["heuristic_name"] == row["nomination"]["name"]
1356 assert row["heuristic_junk"] == row["nomination"]["junk_shape"]
1357 assert row["sources"] == sorted({
1358 item["source"] for item in row["nomination"]["items"]
1359 })
1360 # Digest on stdout: names the bundle file, instructs reading it before
1361 # judging, and carries exactly one line per nomination id.
1362 assert str(bundle_path) in result.stdout
1363 assert "before judging" in result.stdout
1364 for row in payload["nominations"]:
1365 matching = [
1366 line for line in result.stdout.splitlines()
1367 if line.startswith(f"{row['id']} | ")
1368 ]
1369 assert len(matching) == 1
1370 # No queue writes on leg 1.
1371 assert not (tmp_path / "research.db").exists()
1372
1373
1374 def test_discovery_cli_nominate_only_source_boundary_rides_into_bundle(tmp_path):
1375 result = _run_nominate_only(tmp_path, "--search", "reddit")
1376 assert result.returncode == 0, result.stderr
1377 payload = json.loads(
1378 (tmp_path / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME).read_text(
1379 encoding="utf-8"
1380 )
1381 )
1382 assert payload["context"]["requested_sources"] == ["reddit"]
1383 assert payload["context"]["enrichment_source_boundary"] == ["reddit"]
1384 for row in payload["nominations"]:
1385 assert row["sources"] == ["reddit"]
1386
1387
1388 def test_discovery_cli_nominate_only_mock_is_deterministic(tmp_path):
1389 """Two mock leg-1 runs agree on every nomination row and digest line
1390 (only bundle_id/generated_at/path may differ)."""
1391 def run_leg(save_dir):
1392 save_dir.mkdir()
1393 result = _run_nominate_only(save_dir)
1394 assert result.returncode == 0, result.stderr
1395 payload = json.loads(
1396 (save_dir / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME).read_text(
1397 encoding="utf-8"
1398 )
1399 )
1400 return payload, result.stdout
1401
1402 first_payload, first_out = run_leg(tmp_path / "a")
1403 second_payload, second_out = run_leg(tmp_path / "b")
1404 assert first_payload["nominations"] == second_payload["nominations"]
1405 assert (first_payload["from_date"], first_payload["to_date"]) == (
1406 second_payload["from_date"], second_payload["to_date"],
1407 )
1408 ids = [row["id"] for row in first_payload["nominations"]]
1409
1410 def digest_lines(out: str) -> list[str]:
1411 return [
1412 line for line in out.splitlines()
1413 if any(line.startswith(f"{row_id} | ") for row_id in ids)
1414 ]
1415
1416 assert digest_lines(first_out) == digest_lines(second_out)
1417
1418
1419 def test_discovery_cli_nominate_only_shallow_marks_tier(tmp_path):
1420 result = _run_nominate_only(tmp_path, "--discover-shallow")
1421 assert result.returncode == 0, result.stderr
1422 payload = json.loads(
1423 (tmp_path / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME).read_text(
1424 encoding="utf-8"
1425 )
1426 )
1427 assert payload["tier"] == "shallow"
1428
1429
1430 def test_discovery_cli_nominate_only_zero_nominations_nothing_solid(tmp_path, capsys):
1431 """An empty sweep short-circuits to the existing nothing-solid brief:
1432 exit 0, NO bundle file, nothing for later legs."""
1433 parser = cli.build_parser()
1434 args, _extra = parser.parse_known_args(
1435 ["--discover", "AI agents", "--save-dir", str(tmp_path), "--nominate-only"]
1436 )
1437
1438 def empty_fetch(source, plan, *, from_date, to_date, depth, mock, config, keyword_gate=True):
1439 return [], None
1440
1441 with mock.patch.object(
1442 pipeline, "available_sources", return_value=["hackernews"],
1443 ), mock.patch.object(
1444 pipeline, "_fetch_discovery_source", side_effect=empty_fetch,
1445 ), mock.patch.object(pipeline, "enrich_nominations") as enrich:
1446 assert cli._run_discover_protocol_leg(args, {}) == 0
1447
1448 enrich.assert_not_called()
1449 out = capsys.readouterr().out
1450 assert "Nothing solid this window." in out
1451 assert not (tmp_path / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME).exists()
1452 assert not (tmp_path / "research.db").exists()
1453
1454
1455 def test_discovery_cli_nominate_only_skips_enrichment_providers_and_queue(tmp_path):
1456 """Spies on the leg-1 boundary: enrichment, provider resolution, and the
1457 queue hook are never touched, and no research.db appears."""
1458 parser = cli.build_parser()
1459 args, _extra = parser.parse_known_args(
1460 ["--discover", "AI agents", "--mock", "--save-dir", str(tmp_path), "--nominate-only"]
1461 )
1462 with mock.patch.object(pipeline, "enrich_nominations") as enrich, \
1463 mock.patch.object(pipeline.providers, "resolve_runtime") as resolve, \
1464 mock.patch.object(cli, "_annotate_and_record_discovery_queue") as queue_hook:
1465 assert cli._run_discover_protocol_leg(args, {}) == 0
1466 enrich.assert_not_called()
1467 resolve.assert_not_called()
1468 queue_hook.assert_not_called()
1469 assert (tmp_path / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME).is_file()
1470 assert not (tmp_path / "research.db").exists()
1471
1472
1473 def test_discover_handoff_state_dir_scopes_to_save_dir_then_config(tmp_path, monkeypatch):
1474 """One resolver for all three legs: save dir when given, else config dir
1475 (the same base the report cache uses)."""
1476 save_dir = tmp_path / "client"
1477 resolved = cli._discover_handoff_state_dir(argparse.Namespace(save_dir=str(save_dir)))
1478 assert resolved == save_dir.resolve()
1479
1480 monkeypatch.setattr(cli.env, "CONFIG_DIR", tmp_path / "config")
1481 resolved = cli._discover_handoff_state_dir(argparse.Namespace(save_dir=None))
1482 assert resolved == tmp_path / "config"
1483
1484
1485 def test_discovery_protocol_dispatch_maps_contract_error_to_exit_2(monkeypatch, capsys):
1486 """HandoffContractError raised inside any leg body maps to stderr + exit 2
1487 at the dispatch layer, so U3-U5 leg bodies can raise it freely."""
1488
1489 def _stale_bundle(_args, _config):
1490 raise discovery_handoff.HandoffContractError(
1491 "Nominations bundle is stale; run a fresh re-sweep."
1492 )
1493
1494 monkeypatch.setattr(cli, "_run_discover_nominate", _stale_bundle)
1495 args = argparse.Namespace(nominate_only=True, judgments=None, finalize=False)
1496 assert cli._run_discover_protocol_leg(args, {}) == 2
1497 err = capsys.readouterr().err
1498 assert "Nominations bundle is stale; run a fresh re-sweep." in err
1499
1500
1501 # --- U4 leg 2: --discover --judgments (resume, enrich, pending report) --------
1502
1503
1504 def _leg1_bundle_payload(save_dir) -> dict:
1505 """Run leg 1 in-process against the save dir and return the bundle JSON."""
1506 parser = cli.build_parser()
1507 args, _extra = parser.parse_known_args(
1508 ["--discover", "AI agents", "--mock", "--save-dir", str(save_dir),
1509 "--nominate-only"]
1510 )
1511 assert cli._run_discover_protocol_leg(args, {}) == 0
1512 return json.loads(
1513 (Path(save_dir) / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME).read_text(
1514 encoding="utf-8"
1515 )
1516 )
1517
1518
1519 def _run_leg2(save_dir, judgments_payload, config=None) -> int:
1520 judgments_path = Path(save_dir) / "judgments.json"
1521 judgments_path.write_text(json.dumps(judgments_payload), encoding="utf-8")
1522 parser = cli.build_parser()
1523 args, _extra = parser.parse_known_args([
1524 "--discover", "AI agents", "--mock", "--save-dir", str(save_dir),
1525 "--judgments", str(judgments_path),
1526 ])
1527 return cli._run_discover_protocol_leg(args, dict(config or {}))
1528
1529
1530 def _rich_enrichment_report(topic: str) -> schema.Report:
1531 """A per-topic fake enrichment corpus with topic-unique URLs so distinct
1532 topics never trip the same-story fold."""
1533 import datetime as _datetime
1534
1535 slug = "".join(ch if ch.isalnum() else "-" for ch in topic.lower())
1536 published = (_datetime.date.today() - _datetime.timedelta(days=1)).isoformat()
1537 items = {
1538 "reddit": [schema.SourceItem(
1539 item_id=f"r-{slug}", source="reddit", title=topic, body=topic,
1540 url=f"https://reddit.com/r/x/{slug}", published_at=published,
1541 engagement={"score": 800, "num_comments": 300}, snippet=topic,
1542 )],
1543 "hackernews": [schema.SourceItem(
1544 item_id=f"h-{slug}", source="hackernews", title=topic, body=topic,
1545 url=f"https://example.com/{slug}", published_at=published,
1546 engagement={"points": 400, "comments": 150}, snippet=topic,
1547 )],
1548 }
1549 return schema.Report(
1550 topic=topic,
1551 range_from="2026-06-10", range_to="2026-07-10",
1552 generated_at="2026-07-10T00:00:00+00:00",
1553 provider_runtime=schema.ProviderRuntime(
1554 reasoning_provider="none",
1555 planner_model="deterministic",
1556 rerank_model="deterministic",
1557 ),
1558 query_plan=schema.QueryPlan(
1559 intent="factual", freshness_mode="balanced_recent",
1560 cluster_mode="none", raw_topic=topic, subqueries=[],
1561 source_weights={},
1562 ),
1563 clusters=[], ranked_candidates=[],
1564 items_by_source=items, errors_by_source={},
1565 )
1566
1567
1568 def test_discovery_cli_resume_pending_report_round_trip(tmp_path, capsys):
1569 """Scenario 8: leg 2 persists ONE pending report - bundle_id binding, a
1570 fresh generated_at (the leg-3 TTL clock), the queue's run_ref format, the
1571 full report with host names and contiguous ranks, and angle inputs keyed
1572 by surviving nomination ids - and prints the angle inputs plus the
1573 finalize instructions. No queue writes, no artifact saves."""
1574 from lib import env as lib_env
1575
1576 bundle_payload = _leg1_bundle_payload(tmp_path)
1577 capsys.readouterr()
1578 ids = [row["id"] for row in bundle_payload["nominations"]]
1579 assert "n1" in ids
1580 judgments = {
1581 "bundle_id": bundle_payload["bundle_id"],
1582 "judgments": [
1583 {"id": "n1", "name": "Renamed Topic One", "junk": False,
1584 "worthiness": 90},
1585 ],
1586 }
1587
1588 def fake_run(*, topic, **_kwargs):
1589 return _rich_enrichment_report(topic)
1590
1591 with mock.patch.object(pipeline, "run", side_effect=fake_run):
1592 assert _run_leg2(tmp_path, judgments) == 0
1593 out = capsys.readouterr().out
1594
1595 pending_path = tmp_path / discovery_handoff.PENDING_REPORT_FILENAME
1596 assert pending_path.is_file()
1597 payload = json.loads(pending_path.read_text(encoding="utf-8"))
1598 assert payload["bundle_id"] == bundle_payload["bundle_id"]
1599 # Fresh TTL clock: generated_at is the resume run's, not the sweep's.
1600 assert lib_env.is_timestamp_fresh(payload["generated_at"], 3600)
1601 assert payload["generated_at"] != bundle_payload["generated_at"]
1602 assert payload["run_ref"] == f"discover:AI agents:{payload['generated_at']}"
1603
1604 report_dict = payload["report"]
1605 topic_names = [topic["name"] for topic in report_dict["topics"]]
1606 assert "Renamed Topic One" in topic_names
1607 assert [topic["rank"] for topic in report_dict["topics"]] == list(
1608 range(1, len(topic_names) + 1)
1609 )
1610 assert all(topic["evidence_urls"] for topic in report_dict["topics"])
1611
1612 angle_inputs = payload["angle_inputs"]
1613 assert angle_inputs
1614 assert set(angle_inputs) <= set(ids)
1615 assert angle_inputs["n1"]["name"] == "Renamed Topic One"
1616 for entry in angle_inputs.values():
1617 assert set(entry) == {"name", "titles", "top_comment", "engagement"}
1618
1619 # stdout: angle inputs + instruction block with the bundle_id echo.
1620 assert "Renamed Topic One" in out
1621 assert bundle_payload["bundle_id"] in out
1622 assert "--discover --finalize" in out
1623 # No queue writes and no artifact saves on this leg.
1624 assert not (tmp_path / "research.db").exists()
1625 assert not list(tmp_path.glob("*discover-raw*"))
1626
1627
1628 def test_discovery_cli_resume_zero_survivors_renders_nothing_solid(tmp_path, capsys):
1629 """Scenario 9: every nomination host-junked - leg 2 renders the honest
1630 nothing-solid brief itself, exits 0, and leaves NO pending file (there is
1631 nothing for leg 3 to finalize)."""
1632 bundle_payload = _leg1_bundle_payload(tmp_path)
1633 capsys.readouterr()
1634 judgments = {
1635 "bundle_id": bundle_payload["bundle_id"],
1636 "judgments": [
1637 {"id": row["id"], "junk": True}
1638 for row in bundle_payload["nominations"]
1639 ],
1640 }
1641 with mock.patch.object(pipeline, "enrich_nominations") as enrich:
1642 assert _run_leg2(tmp_path, judgments) == 0
1643 enrich.assert_not_called()
1644 out = capsys.readouterr().out
1645 assert "Nothing solid this window." in out
1646 assert not (tmp_path / discovery_handoff.PENDING_REPORT_FILENAME).exists()
1647 assert not (tmp_path / "research.db").exists()
1648
1649
1650 def test_discovery_cli_resume_never_resolves_providers_or_queue(tmp_path, capsys):
1651 """Leg 2 has no LLM pass (the host IS the judge) and no queue write (the
1652 queue belongs to leg 3): provider resolution and the queue hook are never
1653 touched on the resume leg."""
1654 bundle_payload = _leg1_bundle_payload(tmp_path)
1655 capsys.readouterr()
1656 judgments = {"bundle_id": bundle_payload["bundle_id"], "judgments": []}
1657
1658 def fake_run(*, topic, **_kwargs):
1659 return _rich_enrichment_report(topic)
1660
1661 with mock.patch.object(pipeline, "run", side_effect=fake_run), \
1662 mock.patch.object(pipeline.providers, "resolve_runtime") as resolve, \
1663 mock.patch.object(cli, "_annotate_and_record_discovery_queue") as queue_hook:
1664 assert _run_leg2(tmp_path, judgments) == 0
1665
1666 resolve.assert_not_called()
1667 queue_hook.assert_not_called()
1668
1669
1670 def test_discovery_cli_resume_without_bundle_exits_2(tmp_path):
1671 """A resume against an empty state dir is a contract failure: exit 2 with
1672 the searched locations and the re-sweep remedy on stderr."""
1673 judgments_path = tmp_path / "judgments.json"
1674 judgments_path.write_text(
1675 json.dumps({"bundle_id": "deadbeef", "judgments": []}), encoding="utf-8"
1676 )
1677 result = _run_protocol_cli([
1678 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
1679 "--judgments", str(judgments_path),
1680 ])
1681 assert result.returncode == 2, result.stderr
1682 assert "No discovery nominations bundle found" in result.stderr
1683 assert "--discover --nominate-only" in result.stderr
1684
1685
1686 def test_discovery_cli_resume_full_mock_offline_is_deterministic(tmp_path):
1687 """Scenario 10: a full mock leg 2 (mock judgments against a mock leg-1
1688 bundle, --save-dir scoped) runs offline end to end - exit 0, a pending
1689 report bound to the bundle, deterministic angle inputs across two resumes,
1690 and zero queue writes."""
1691 leg1 = _run_nominate_only(tmp_path)
1692 assert leg1.returncode == 0, leg1.stderr
1693 bundle_payload = json.loads(
1694 (tmp_path / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME).read_text(
1695 encoding="utf-8"
1696 )
1697 )
1698 rows = bundle_payload["nominations"]
1699 keep = [row["id"] for row in rows if not row["heuristic_junk"]][:2]
1700 assert keep, "mock sweep should nominate at least one non-junk topic"
1701 judgments_path = tmp_path / "judgments.json"
1702 judgments_path.write_text(json.dumps({
1703 "bundle_id": bundle_payload["bundle_id"],
1704 "judgments": [
1705 {"id": keep[0], "name": "Renamed Mock Topic", "junk": False,
1706 "worthiness": 90},
1707 *[
1708 {"id": row["id"], "junk": True}
1709 for row in rows if row["id"] not in keep
1710 ],
1711 ],
1712 }), encoding="utf-8")
1713
1714 def run_leg2():
1715 return _run_protocol_cli(
1716 [
1717 "--discover", "AI agents", "--mock",
1718 "--save-dir", str(tmp_path),
1719 "--judgments", str(judgments_path),
1720 ],
1721 env_overrides={"LAST30DAYS_DEFAULT_SEARCH": ""},
1722 )
1723
1724 first = run_leg2()
1725 assert first.returncode == 0, first.stderr
1726 pending_path = tmp_path / discovery_handoff.PENDING_REPORT_FILENAME
1727 first_payload = json.loads(pending_path.read_text(encoding="utf-8"))
1728 second = run_leg2()
1729 assert second.returncode == 0, second.stderr
1730 second_payload = json.loads(pending_path.read_text(encoding="utf-8"))
1731
1732 assert first_payload["bundle_id"] == bundle_payload["bundle_id"]
1733 assert set(first_payload["angle_inputs"]) <= set(keep)
1734 # Deterministic: two mock resumes agree on every angle input.
1735 assert first_payload["angle_inputs"] == second_payload["angle_inputs"]
1736 assert "Renamed Mock Topic" in first.stdout
1737 assert bundle_payload["bundle_id"] in first.stdout
1738 assert "--discover --finalize" in first.stdout
1739 assert not (tmp_path / "research.db").exists()
1740
1741
1742 # --- U5 leg 3: --discover --finalize (angles, render, artifacts, queue) --------
1743
1744
1745 def _fresh_generated_at() -> str:
1746 import datetime as _datetime
1747
1748 return _datetime.datetime.now(_datetime.timezone.utc).isoformat()
1749
1750
1751 def _write_pending_report(
1752 save_dir,
1753 names=("Gemma 4 chat templates",),
1754 bundle_id="cafe1234cafe1234",
1755 ) -> dict:
1756 """Write a synthetic (schema-true) leg-2 pending report into save_dir and
1757 return its payload. Mirrors the exact U4 writer shape: full schema round
1758 trip of the report plus angle_inputs keyed by surviving nomination id."""
1759 generated_at = _fresh_generated_at()
1760 report = _queue_report(list(names))
1761 report.generated_at = generated_at
1762 payload = {
1763 "kind": schema.DISCOVERY_PENDING_KIND,
1764 "schema_version": schema.DISCOVERY_PENDING_SCHEMA_VERSION,
1765 "bundle_id": bundle_id,
1766 "generated_at": generated_at,
1767 "run_ref": f"discover:{report.domain or 'trending'}:{generated_at}",
1768 "report": schema.to_dict(report),
1769 "angle_inputs": {
1770 f"n{position}": {
1771 "name": name,
1772 "titles": f"Listing title about {name}",
1773 "top_comment": "",
1774 "engagement": "120 native interactions across reddit",
1775 }
1776 for position, name in enumerate(names, start=1)
1777 },
1778 }
1779 save_dir = Path(save_dir)
1780 save_dir.mkdir(parents=True, exist_ok=True)
1781 (save_dir / discovery_handoff.PENDING_REPORT_FILENAME).write_text(
1782 json.dumps(payload), encoding="utf-8"
1783 )
1784 return payload
1785
1786
1787 def _write_angles_file(save_dir, bundle_id, rows) -> Path:
1788 path = Path(save_dir) / "angles.json"
1789 path.write_text(
1790 json.dumps({"bundle_id": bundle_id, "angles": rows}), encoding="utf-8"
1791 )
1792 return path
1793
1794
1795 def _run_leg3(save_dir, config=None, *extra_argv) -> int:
1796 """Run leg 3 in-process, non-mock (finalize is offline by design)."""
1797 parser = cli.build_parser()
1798 args, _extra = parser.parse_known_args([
1799 "--discover", "AI agents", "--save-dir", str(save_dir),
1800 "--save-suffix", os.urandom(4).hex(), "--finalize", *extra_argv,
1801 ])
1802 return cli._run_discover_protocol_leg(args, dict(config or {}))
1803
1804
1805 def test_discovery_report_round_trips_through_schema_dicts():
1806 """to_dict -> JSON -> discovery_report_from_dict is lossless: the exact
1807 round trip leg 3 performs on the pending report."""
1808 import dataclasses
1809
1810 original = _queue_report(["Gemma 4 chat templates"])
1811 original.topics[0] = dataclasses.replace(
1812 original.topics[0],
1813 top_comment='"Sharp take" - u/dev (1,200 votes)',
1814 corroboration_count=2,
1815 evidence_urls=["https://reddit.com/r/x/1"],
1816 )
1817 payload = json.loads(json.dumps(schema.to_dict(original)))
1818 assert schema.discovery_report_from_dict(payload) == original
1819
1820
1821 def test_discovery_cli_finalize_applies_host_angles_and_records_queue(tmp_path, capsys):
1822 """Scenario: finalize with an angles file renders host angle lines
1823 verbatim, saves the discovery artifact, and records the queue under the
1824 LEG-2 run identity (the pending report's run_ref)."""
1825 import sqlite3 as _sqlite3
1826
1827 pending = _write_pending_report(tmp_path)
1828 angles_path = _write_angles_file(tmp_path, pending["bundle_id"], [
1829 {"id": "n1",
1830 "podcast": "Is Gemma 4 chat templating a lock-in play?",
1831 "x_article": "Five Gemma 4 template changes worth writing about."},
1832 ])
1833
1834 assert _run_leg3(tmp_path, None, "--angles", str(angles_path)) == 0
1835 out = capsys.readouterr().out
1836 assert "## 1. Gemma 4 chat templates" in out
1837 assert (
1838 "**Podcast angle:** Is Gemma 4 chat templating a lock-in play?" in out
1839 )
1840 assert (
1841 "**X article angle:** Five Gemma 4 template changes worth writing about."
1842 in out
1843 )
1844 # First-ever topic: nothing prior to annotate from.
1845 assert "**Pipeline:**" not in out
1846 # Artifact saved via the existing O_EXCL discovery path.
1847 assert list(tmp_path.glob("*discover-raw*"))
1848 # Queue row recorded under the pending report's run_ref.
1849 conn = _sqlite3.connect(tmp_path / "research.db")
1850 conn.row_factory = _sqlite3.Row
1851 row = dict(conn.execute("SELECT * FROM discovery_topics").fetchone())
1852 conn.close()
1853 assert row["name"] == "Gemma 4 chat templates"
1854 assert row["surface_count"] == 1
1855 assert row["last_run_ref"] == pending["run_ref"]
1856 # Pending file left in place: idempotent retries are a design requirement.
1857 assert (tmp_path / discovery_handoff.PENDING_REPORT_FILENAME).is_file()
1858
1859
1860 def test_discovery_cli_finalize_without_angles_renders_angle_less_brief(tmp_path, capsys):
1861 """Omitting --angles is legal: the brief ships without angle lines and the
1862 queue still records the surfacing."""
1863 _write_pending_report(tmp_path)
1864
1865 assert _run_leg3(tmp_path) == 0
1866 out = capsys.readouterr().out
1867 assert "## 1. Gemma 4 chat templates" in out
1868 assert "**Podcast angle:**" not in out
1869 assert "**X article angle:**" not in out
1870
1871 import sqlite3 as _sqlite3
1872 conn = _sqlite3.connect(tmp_path / "research.db")
1873 rows = conn.execute(
1874 "SELECT name, surface_count FROM discovery_topics"
1875 ).fetchall()
1876 conn.close()
1877 assert rows == [("Gemma 4 chat templates", 1)]
1878
1879
1880 def test_discovery_cli_double_finalize_same_run_ref_counts_once(tmp_path, capsys):
1881 """AE6: a finalize retry (same pending report, same run_ref) neither
1882 double-counts the surfacing nor annotates the retry as a resurfacing -
1883 the rendered brief is stable across retries."""
1884 import sqlite3 as _sqlite3
1885
1886 _write_pending_report(tmp_path)
1887
1888 assert _run_leg3(tmp_path) == 0
1889 first = capsys.readouterr().out
1890 assert _run_leg3(tmp_path) == 0
1891 second = capsys.readouterr().out
1892
1893 assert "## 1. Gemma 4 chat templates" in second
1894 # The retry must not describe itself as a 2nd surfacing.
1895 assert "**Pipeline:**" not in second
1896 assert first == second
1897
1898 conn = _sqlite3.connect(tmp_path / "research.db")
1899 row = conn.execute(
1900 "SELECT surface_count, covered_at FROM discovery_topics"
1901 ).fetchone()
1902 conn.close()
1903 assert row == (1, None)
1904
1905
1906 def test_discovery_cli_finalize_retry_with_prior_history_renders_identically(tmp_path, capsys):
1907 """F3: a finalize retry over a topic WITH pre-run history reconstructs
1908 the pre-run queue state (surface_count minus this run's own recording)
1909 instead of nulling the prior - the retry renders the same 'surfaced Nth
1910 time' line as the first attempt."""
1911 import sqlite3 as _sqlite3
1912
1913 import store
1914
1915 with store.scoped_db(tmp_path / "research.db"):
1916 store.record_discovery_surfacing(
1917 "Gemma 4 chat templates", domain="AI agents", run_ref="run-old",
1918 as_of="2026-07-13",
1919 )
1920 _write_pending_report(tmp_path)
1921
1922 assert _run_leg3(tmp_path) == 0
1923 first = capsys.readouterr().out
1924 assert "**Pipeline:** surfaced 2nd time" in first
1925
1926 assert _run_leg3(tmp_path) == 0
1927 second = capsys.readouterr().out
1928 assert "**Pipeline:** surfaced 2nd time" in second
1929 assert first == second
1930
1931 # The retry never double-counted the surfacing.
1932 conn = _sqlite3.connect(tmp_path / "research.db")
1933 count = conn.execute(
1934 "SELECT surface_count FROM discovery_topics"
1935 ).fetchone()[0]
1936 conn.close()
1937 assert count == 2
1938
1939
1940 def test_discovery_cli_finalize_retry_keeps_covered_history(tmp_path, capsys):
1941 """F3: the reconstructed pre-run state keeps the prior's covered mark
1942 (covered_at intact), so a retry still renders 'marked covered'."""
1943 import store
1944
1945 with store.scoped_db(tmp_path / "research.db"):
1946 store.record_discovery_surfacing(
1947 "Gemma 4 chat templates", domain="AI agents", run_ref="run-old",
1948 as_of="2026-07-13",
1949 )
1950 store.mark_discovery_covered("Gemma 4 chat templates", as_of="2026-07-14")
1951 _write_pending_report(tmp_path)
1952
1953 assert _run_leg3(tmp_path) == 0
1954 first = capsys.readouterr().out
1955 assert "**Pipeline:** surfaced 2nd time, marked covered" in first
1956
1957 assert _run_leg3(tmp_path) == 0
1958 second = capsys.readouterr().out
1959 assert "**Pipeline:** surfaced 2nd time, marked covered" in second
1960 assert first == second
1961
1962 with store.scoped_db(tmp_path / "research.db"):
1963 row = store.match_discovery_topic("Gemma 4 chat templates")
1964 assert row is not None
1965 assert row["status"] == "covered"
1966 assert row["covered_at"] == "2026-07-14"
1967
1968
1969 def test_discovery_cli_finalize_new_run_ref_still_increments(tmp_path, capsys):
1970 """Scenario 8: the guard is per-run. A LATER protocol round (fresh pending
1971 report, fresh run_ref) increments and annotates normally."""
1972 import sqlite3 as _sqlite3
1973
1974 _write_pending_report(tmp_path)
1975 assert _run_leg3(tmp_path) == 0
1976 capsys.readouterr()
1977
1978 # A new leg-2 round over the same story: new generated_at -> new run_ref.
1979 _write_pending_report(tmp_path)
1980 assert _run_leg3(tmp_path) == 0
1981 out = capsys.readouterr().out
1982 assert "**Pipeline:** surfaced 2nd time" in out
1983
1984 conn = _sqlite3.connect(tmp_path / "research.db")
1985 count = conn.execute(
1986 "SELECT surface_count FROM discovery_topics"
1987 ).fetchone()[0]
1988 conn.close()
1989 assert count == 2
1990
1991
1992 def test_discovery_cli_finalize_queue_failure_degrades_to_warning(tmp_path, monkeypatch, capsys):
1993 """The finalize queue call sits behind the same guarded hook as the
1994 one-shot: a broken research.db degrades to a stderr warning and the brief
1995 still prints (exit 0)."""
1996 import sqlite3 as _sqlite3
1997
1998 import store
1999
2000 def _locked(*_args, **_kwargs):
2001 raise _sqlite3.OperationalError("database is locked")
2002
2003 monkeypatch.setattr(store, "record_discovery_surfacing", _locked)
2004 _write_pending_report(tmp_path)
2005
2006 assert _run_leg3(tmp_path) == 0
2007 captured = capsys.readouterr()
2008 assert "## 1. Gemma 4 chat templates" in captured.out
2009 assert "**Pipeline:**" not in captured.out
2010 assert "[last30days] Warning:" in captured.err
2011 assert "database is locked" in captured.err
2012
2013
2014 def test_discovery_cli_finalize_covered_inheritance_on_rename_drift(tmp_path, capsys):
2015 """A host-authored name that fuzzy-matches a covered prior inherits the
2016 covered mark (rename-drift scenario) - same convention as the one-shot."""
2017 import store
2018
2019 with store.scoped_db(tmp_path / "research.db"):
2020 store.record_discovery_surfacing(
2021 "Gemma 4 chat templates", domain="AI agents", run_ref="run-old",
2022 as_of="2026-07-13",
2023 )
2024 store.mark_discovery_covered("Gemma 4 chat templates", as_of="2026-07-14")
2025
2026 _write_pending_report(tmp_path, names=("Gemma 4 template fixes",))
2027 assert _run_leg3(tmp_path) == 0
2028 out = capsys.readouterr().out
2029 assert "marked covered" in out
2030
2031 with store.scoped_db(tmp_path / "research.db"):
2032 fresh = store.match_discovery_topic("Gemma 4 template fixes")
2033 assert fresh is not None
2034 assert fresh["status"] == "covered"
2035 assert fresh["covered_at"] == "2026-07-14"
2036
2037
2038 def test_discovery_cli_finalize_is_offline(tmp_path, capsys):
2039 """Leg 3 is the cheap leg: no sweep, no enrichment, no provider
2040 resolution - only the pending report, the angles file, and the queue."""
2041 _write_pending_report(tmp_path)
2042 parser = cli.build_parser()
2043 args, _extra = parser.parse_known_args([
2044 "--discover", "AI agents", "--save-dir", str(tmp_path), "--finalize",
2045 ])
2046 with mock.patch.object(pipeline, "run_discover") as sweep, \
2047 mock.patch.object(pipeline, "run") as research, \
2048 mock.patch.object(pipeline, "enrich_nominations") as enrich, \
2049 mock.patch.object(pipeline.providers, "resolve_runtime") as resolve:
2050 assert cli._run_discover_protocol_leg(args, {}) == 0
2051 sweep.assert_not_called()
2052 research.assert_not_called()
2053 enrich.assert_not_called()
2054 resolve.assert_not_called()
2055
2056
2057 def test_discovery_cli_finalize_emit_json_carries_host_angles(tmp_path, capsys):
2058 """--emit json respects the same export contract as the one-shot; host
2059 angles ride in the discovery export fields."""
2060 pending = _write_pending_report(tmp_path)
2061 angles_path = _write_angles_file(tmp_path, pending["bundle_id"], [
2062 {"id": "n1", "podcast": "A hook worth exporting"},
2063 ])
2064 assert _run_leg3(
2065 tmp_path, None, "--angles", str(angles_path), "--emit", "json",
2066 ) == 0
2067 payload = json.loads(capsys.readouterr().out)
2068 assert payload["kind"] == "discovery"
2069 assert payload["results"][0]["podcast_angle"] == "A hook worth exporting"
2070 assert payload["results"][0]["x_article_angle"] is None
2071
2072
2073 def test_discovery_cli_finalize_rejects_html_like_the_one_shot(tmp_path):
2074 """The HTML guard is hoisted to the shared --discover dispatch: finalize
2075 rejects --emit=html before touching any handoff state, even when a valid
2076 pending report exists."""
2077 _write_pending_report(tmp_path)
2078 result = _run_protocol_cli([
2079 "--discover", "AI agents", "--save-dir", str(tmp_path), "--finalize",
2080 "--emit", "html",
2081 ])
2082 assert result.returncode == 2, result.stderr
2083 assert "does not support HTML publishing" in result.stderr
2084
2085
2086 def test_discovery_cli_protocol_legs_reject_as_of_and_html(tmp_path):
2087 """F4: the one-shot's --as-of and HTML guards bind on EVERY protocol leg
2088 before dispatch - legs 1 and 2 must not sweep historical dates or accept
2089 an HTML pipeline discovery does not have."""
2090 nominate = _run_protocol_cli([
2091 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
2092 "--nominate-only", "--as-of", "2026-06-01",
2093 ])
2094 assert nominate.returncode == 2, nominate.stderr
2095 assert "--as-of cannot be used with --discover" in nominate.stderr
2096 assert "current live listings" in nominate.stderr
2097
2098 # The judgments file deliberately does not exist: the guard must fire
2099 # before any handoff file is read.
2100 judgments = _run_protocol_cli([
2101 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
2102 "--judgments", str(tmp_path / "missing-judgments.json"), "--emit=html",
2103 ])
2104 assert judgments.returncode == 2, judgments.stderr
2105 assert "does not support HTML publishing" in judgments.stderr
2106
2107
2108 def test_discovery_cli_finalize_stale_pending_exits_2(tmp_path, capsys):
2109 """TTL is measured from the PENDING report's generated_at (the leg-2
2110 write started a fresh window); a stale one names the resume remedy."""
2111 import datetime as _datetime
2112
2113 stale = (
2114 _datetime.datetime.now(_datetime.timezone.utc)
2115 - _datetime.timedelta(
2116 seconds=discovery_handoff.DISCOVERY_HANDOFF_TTL_SECONDS + 60
2117 )
2118 ).isoformat()
2119 payload = _write_pending_report(tmp_path)
2120 pending_path = tmp_path / discovery_handoff.PENDING_REPORT_FILENAME
2121 payload["generated_at"] = stale
2122 pending_path.write_text(json.dumps(payload), encoding="utf-8")
2123
2124 assert _run_leg3(tmp_path) == 2
2125 err = capsys.readouterr().err
2126 assert "stale" in err
2127 assert "--discover --judgments" in err
2128
2129
2130 def test_discovery_cli_finalize_missing_pending_names_save_dir_only(tmp_path, monkeypatch, capsys):
2131 """With an explicit --save-dir the not-found message names ONLY the
2132 save-dir location (the single handoff store - no config-dir fallback)
2133 plus the resume-leg remedy."""
2134 config_dir = tmp_path / "config"
2135 monkeypatch.setattr(cli.env, "CONFIG_DIR", config_dir)
2136 save_dir = tmp_path / "client"
2137 assert _run_leg3(save_dir) == 2
2138 err = capsys.readouterr().err
2139 assert str(save_dir.resolve() / discovery_handoff.PENDING_REPORT_FILENAME) in err
2140 assert str(config_dir) not in err
2141 assert "--discover --judgments" in err
2142 assert "--discover --nominate-only" in err
2143
2144
2145 def test_discovery_cli_finalize_angles_bundle_mismatch_exits_2(tmp_path, capsys):
2146 pending = _write_pending_report(tmp_path)
2147 angles_path = _write_angles_file(tmp_path, "deadbeefdeadbeef", [
2148 {"id": "n1", "podcast": "Bound to the wrong bundle"},
2149 ])
2150 assert _run_leg3(tmp_path, None, "--angles", str(angles_path)) == 2
2151 err = capsys.readouterr().err
2152 assert "deadbeefdeadbeef" in err
2153 assert pending["bundle_id"] in err
2154
2155
2156 def test_discovery_cli_finalize_invalid_pending_json_exits_2(tmp_path, capsys):
2157 (tmp_path / discovery_handoff.PENDING_REPORT_FILENAME).write_text(
2158 "{not json", encoding="utf-8"
2159 )
2160 assert _run_leg3(tmp_path) == 2
2161 assert "JSON" in capsys.readouterr().err
2162
2163
2164 def test_discovery_cli_finalize_malformed_pending_report_body_exits_2(tmp_path, capsys):
2165 """F7: a pending file whose top-level envelope validates but whose report
2166 body is structurally incomplete (missing required report keys) is a
2167 contract failure with the resume remedy - exit 2, never a traceback."""
2168 payload = _write_pending_report(tmp_path)
2169 payload["report"] = {"domain": "AI agents", "topics": []} # no range/dates
2170 (tmp_path / discovery_handoff.PENDING_REPORT_FILENAME).write_text(
2171 json.dumps(payload), encoding="utf-8"
2172 )
2173 assert _run_leg3(tmp_path) == 2
2174 err = capsys.readouterr().err
2175 assert "malformed" in err
2176 assert "--discover --judgments" in err
2177
2178
2179 def test_discovery_cli_finalize_wrong_kind_or_version_exits_2(tmp_path, capsys):
2180 payload = _write_pending_report(tmp_path)
2181 pending_path = tmp_path / discovery_handoff.PENDING_REPORT_FILENAME
2182
2183 payload["kind"] = "discovery-nominations"
2184 pending_path.write_text(json.dumps(payload), encoding="utf-8")
2185 assert _run_leg3(tmp_path) == 2
2186 assert "discovery-nominations" in capsys.readouterr().err
2187
2188 payload["kind"] = schema.DISCOVERY_PENDING_KIND
2189 payload["schema_version"] = "99.0"
2190 pending_path.write_text(json.dumps(payload), encoding="utf-8")
2191 assert _run_leg3(tmp_path) == 2
2192 assert "99.0" in capsys.readouterr().err
2193
2194
2195 @pytest.mark.skipif(
2196 hasattr(os, "geteuid") and os.geteuid() == 0,
2197 reason="root ignores directory permission bits",
2198 )
2199 def test_discovery_cli_resume_unwritable_pending_write_is_contract_error(tmp_path, capsys):
2200 """F9: the leg-2 pending-report write gets the same fail-closed treatment
2201 as the bundle write - a read-only state dir is HandoffContractError
2202 (exit 2 naming the path), never a raw OSError traceback."""
2203 bundle_payload = _leg1_bundle_payload(tmp_path)
2204 capsys.readouterr()
2205 judgments_path = tmp_path / "judgments.json"
2206 judgments_path.write_text(json.dumps({
2207 "bundle_id": bundle_payload["bundle_id"],
2208 "judgments": [],
2209 }), encoding="utf-8")
2210 parser = cli.build_parser()
2211 args, _extra = parser.parse_known_args([
2212 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
2213 "--judgments", str(judgments_path),
2214 ])
2215
2216 def fake_run(*, topic, **_kwargs):
2217 return _rich_enrichment_report(topic)
2218
2219 tmp_path.chmod(0o500)
2220 try:
2221 with mock.patch.object(pipeline, "run", side_effect=fake_run):
2222 assert cli._run_discover_protocol_leg(args, {}) == 2
2223 finally:
2224 tmp_path.chmod(0o700)
2225 err = capsys.readouterr().err
2226 assert "Could not write pending discovery report" in err
2227 assert str(
2228 tmp_path.resolve() / discovery_handoff.PENDING_REPORT_FILENAME
2229 ) in err
2230
2231
2232 # --- F11: cross-round pending invalidation ------------------------------------
2233
2234
2235 def test_discovery_cli_nominate_clears_stale_pending_from_prior_round(tmp_path, capsys):
2236 """F11: a fresh leg-1 bundle starts a NEW protocol round - any pending
2237 report left by a prior round is deleted, so a bare --finalize afterwards
2238 is not-found (exit 2) instead of silently consuming stale state."""
2239 _write_pending_report(tmp_path) # prior round's leg-2 output
2240 _leg1_bundle_payload(tmp_path) # fresh round: leg 1 writes a new bundle
2241 capsys.readouterr()
2242 assert not (tmp_path / discovery_handoff.PENDING_REPORT_FILENAME).exists()
2243
2244 assert _run_leg3(tmp_path) == 2
2245 assert "No pending discovery report found" in capsys.readouterr().err
2246
2247
2248 def test_discovery_cli_resume_nothing_solid_clears_stale_pending(tmp_path, capsys):
2249 """F11: a zero-survivor leg 2 wrote no pending file THIS round, so a
2250 stale one from an earlier round must not survive it - bare --finalize
2251 afterwards exits 2 not-found."""
2252 bundle_payload = _leg1_bundle_payload(tmp_path)
2253 _write_pending_report(tmp_path) # stale pending from an earlier round
2254 judgments = {
2255 "bundle_id": bundle_payload["bundle_id"],
2256 "judgments": [
2257 {"id": row["id"], "junk": True}
2258 for row in bundle_payload["nominations"]
2259 ],
2260 }
2261 with mock.patch.object(pipeline, "enrich_nominations") as enrich:
2262 assert _run_leg2(tmp_path, judgments) == 0
2263 enrich.assert_not_called()
2264 capsys.readouterr()
2265 assert not (tmp_path / discovery_handoff.PENDING_REPORT_FILENAME).exists()
2266
2267 assert _run_leg3(tmp_path) == 2
2268 assert "No pending discovery report found" in capsys.readouterr().err
2269
2270
2271 # --- F19: mock/real handoff provenance must match across legs ------------------
2272
2273
2274 def test_discovery_cli_resume_rejects_mock_state_mismatch(tmp_path, capsys):
2275 """F19: a leg-2 run whose --mock flag disagrees with the bundle's stamped
2276 provenance exits 2 before any resume work; matching modes still pass."""
2277 bundle_payload = _leg1_bundle_payload(tmp_path) # mock-born bundle
2278 capsys.readouterr()
2279 judgments_path = tmp_path / "judgments.json"
2280 judgments_path.write_text(json.dumps({
2281 "bundle_id": bundle_payload["bundle_id"],
2282 "judgments": [],
2283 }), encoding="utf-8")
2284 parser = cli.build_parser()
2285 bundle_path = tmp_path / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME
2286
2287 # Real leg 2 over the mock-born bundle: exit 2, the resume never runs.
2288 args, _extra = parser.parse_known_args([
2289 "--discover", "AI agents", "--save-dir", str(tmp_path),
2290 "--judgments", str(judgments_path),
2291 ])
2292 with mock.patch.object(pipeline, "run_discover_resume") as resume:
2293 assert cli._run_discover_protocol_leg(args, {}) == 2
2294 resume.assert_not_called()
2295 err = capsys.readouterr().err
2296 assert "mock-born state cannot be finalized by a real run" in err
2297
2298 # Mock leg 2 over a real bundle: the inverse also exits 2.
2299 payload = json.loads(bundle_path.read_text(encoding="utf-8"))
2300 payload["mock"] = False
2301 bundle_path.write_text(json.dumps(payload), encoding="utf-8")
2302 args, _extra = parser.parse_known_args([
2303 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
2304 "--judgments", str(judgments_path),
2305 ])
2306 with mock.patch.object(pipeline, "run_discover_resume") as resume:
2307 assert cli._run_discover_protocol_leg(args, {}) == 2
2308 resume.assert_not_called()
2309 err = capsys.readouterr().err
2310 assert "cannot be finalized by a --mock run" in err
2311
2312 # Matching modes pass the parity gate and reach the resume engine.
2313 payload["mock"] = True
2314 bundle_path.write_text(json.dumps(payload), encoding="utf-8")
2315 with mock.patch.object(pipeline, "enrich_nominations", return_value=[]):
2316 assert cli._run_discover_protocol_leg(args, {}) == 0
2317 capsys.readouterr()
2318
2319
2320 def test_discovery_cli_finalize_rejects_mock_state_mismatch(tmp_path, capsys):
2321 """F19: finalize enforces the same provenance parity against the pending
2322 report's stamped mock flag, in both directions."""
2323 _write_pending_report(tmp_path) # real pending (no mock stamp = real)
2324 parser = cli.build_parser()
2325 args, _extra = parser.parse_known_args([
2326 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
2327 "--finalize",
2328 ])
2329 assert cli._run_discover_protocol_leg(args, {}) == 2
2330 assert "cannot be finalized by a --mock run" in capsys.readouterr().err
2331
2332 # Mock-born pending finalized by a real run: inverse direction.
2333 pending_path = tmp_path / discovery_handoff.PENDING_REPORT_FILENAME
2334 payload = json.loads(pending_path.read_text(encoding="utf-8"))
2335 payload["mock"] = True
2336 pending_path.write_text(json.dumps(payload), encoding="utf-8")
2337 assert _run_leg3(tmp_path) == 2
2338 assert (
2339 "mock-born state cannot be finalized by a real run"
2340 in capsys.readouterr().err
2341 )
2342
2343
2344 # --- F1: degraded sweep state survives the protocol (strict exit on legs) ------
2345
2346
2347 def _degraded_nominate_result() -> pipeline.DiscoverNominateResult:
2348 from_date, to_date = dates.get_date_range(30)
2349 nomination = pipeline.Nomination(
2350 name="Agent SDK Wars",
2351 seed_score=61.0,
2352 items=[_item(
2353 "hn1", "hackernews", "Agent SDK Wars heat up",
2354 engagement={"points": 900, "comments": 400},
2355 )],
2356 summary="Agent SDK Wars heat up across the listings.",
2357 junk_shape=False,
2358 worthiness=None,
2359 )
2360 return pipeline.DiscoverNominateResult(
2361 plan=schema.DiscoveryPlan(
2362 domain="AI agents", category=None, subreddits=[],
2363 sources=["hackernews"],
2364 ),
2365 from_date=from_date,
2366 to_date=to_date,
2367 source_status={
2368 "hackernews": schema.SourceOutcome(
2369 source="hackernews", state="ok", items_returned=1,
2370 ),
2371 "reddit": schema.SourceOutcome(
2372 source="reddit", state=schema.UNREACHABLE, detail="dns failure",
2373 ),
2374 },
2375 pool=[(nomination, "c-agent")],
2376 )
2377
2378
2379 def test_discovery_protocol_strict_exit_and_degraded_state_survive_all_legs(tmp_path, capsys):
2380 """F1: the leg-1 sweep's degraded source outcomes ride the bundle into
2381 leg 2's report and pending file, and on into leg 3's brief - and under
2382 LAST30DAYS_STRICT_EXIT every leg renders normally but exits 3, mirroring
2383 the one-shot's strict-exit contract."""
2384 strict = {"LAST30DAYS_STRICT_EXIT": "1"}
2385 parser = cli.build_parser()
2386
2387 # Leg 1: bundle written (render/output still happens), exit shifts to 3.
2388 args1, _extra = parser.parse_known_args([
2389 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
2390 "--nominate-only",
2391 ])
2392 with mock.patch.object(
2393 pipeline, "run_discover_nominate",
2394 return_value=_degraded_nominate_result(),
2395 ):
2396 assert cli._run_discover_protocol_leg(args1, strict) == 3
2397 captured = capsys.readouterr()
2398 assert "strict-exit: degraded sources: reddit" in captured.err
2399 bundle_payload = json.loads(
2400 (tmp_path / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME).read_text(
2401 encoding="utf-8"
2402 )
2403 )
2404 assert bundle_payload["source_status"]["reddit"]["state"] == schema.UNREACHABLE
2405
2406 # Leg 2: the restored sweep status reaches the pending report (degraded
2407 # warning included) and the exit code stays strict.
2408 judgments = {
2409 "bundle_id": bundle_payload["bundle_id"],
2410 "judgments": [{"id": "n1", "junk": False, "worthiness": 80}],
2411 }
2412
2413 def fake_run(*, topic, **_kwargs):
2414 return _rich_enrichment_report(topic)
2415
2416 with mock.patch.object(pipeline, "run", side_effect=fake_run):
2417 assert _run_leg2(tmp_path, judgments, config=strict) == 3
2418 captured = capsys.readouterr()
2419 assert "strict-exit: degraded sources: reddit" in captured.err
2420 pending_payload = json.loads(
2421 (tmp_path / discovery_handoff.PENDING_REPORT_FILENAME).read_text(
2422 encoding="utf-8"
2423 )
2424 )
2425 assert pending_payload["report"]["source_status"]["reddit"]["state"] == (
2426 schema.UNREACHABLE
2427 )
2428 assert any(
2429 "Some discovery sources degraded: reddit" in warning
2430 for warning in pending_payload["report"]["warnings"]
2431 )
2432
2433 # Leg 3: the brief renders the degraded coverage note and exits 3 too.
2434 args3, _extra = parser.parse_known_args([
2435 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
2436 "--finalize",
2437 ])
2438 assert cli._run_discover_protocol_leg(args3, strict) == 3
2439 captured = capsys.readouterr()
2440 assert "Some discovery sources degraded: reddit" in captured.out
2441 assert "strict-exit: degraded sources: reddit" in captured.err
2442
2443
2444 def test_discovery_nominate_nothing_solid_applies_strict_exit(tmp_path, capsys):
2445 """F1c: the leg-1 nothing-solid short-circuit is a terminal return too -
2446 it renders the brief and still exits 3 under strict exit when the sweep
2447 itself was degraded."""
2448 import dataclasses
2449
2450 strict = {"LAST30DAYS_STRICT_EXIT": "1"}
2451 result = dataclasses.replace(_degraded_nominate_result(), pool=[])
2452 parser = cli.build_parser()
2453 args, _extra = parser.parse_known_args([
2454 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
2455 "--nominate-only",
2456 ])
2457 with mock.patch.object(
2458 pipeline, "run_discover_nominate", return_value=result,
2459 ):
2460 assert cli._run_discover_protocol_leg(args, strict) == 3
2461 captured = capsys.readouterr()
2462 assert "Nothing solid this window." in captured.out
2463 assert "strict-exit: degraded sources: reddit" in captured.err
2464 assert not (tmp_path / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME).exists()
2465
2466
2467 def test_discovery_cli_full_mock_protocol_three_legs_end_to_end(tmp_path):
2468 """The whole protocol offline: nominate -> judgments -> finalize (with
2469 angles) produces a complete brief. Mock finalize writes NO queue rows and
2470 renders deterministically across two runs."""
2471 leg1 = _run_nominate_only(tmp_path)
2472 assert leg1.returncode == 0, leg1.stderr
2473 bundle_payload = json.loads(
2474 (tmp_path / discovery_handoff.NOMINATIONS_BUNDLE_FILENAME).read_text(
2475 encoding="utf-8"
2476 )
2477 )
2478 rows = bundle_payload["nominations"]
2479 keep = [row["id"] for row in rows if not row["heuristic_junk"]][:1]
2480 assert keep, "mock sweep should nominate at least one non-junk topic"
2481 judgments_path = tmp_path / "judgments.json"
2482 judgments_path.write_text(json.dumps({
2483 "bundle_id": bundle_payload["bundle_id"],
2484 "judgments": [
2485 {"id": keep[0], "name": "Renamed Mock Topic", "junk": False,
2486 "worthiness": 90},
2487 *[
2488 {"id": row["id"], "junk": True}
2489 for row in rows if row["id"] not in keep
2490 ],
2491 ],
2492 }), encoding="utf-8")
2493 leg2 = _run_protocol_cli(
2494 [
2495 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
2496 "--judgments", str(judgments_path),
2497 ],
2498 env_overrides={"LAST30DAYS_DEFAULT_SEARCH": ""},
2499 )
2500 assert leg2.returncode == 0, leg2.stderr
2501 pending_payload = json.loads(
2502 (tmp_path / discovery_handoff.PENDING_REPORT_FILENAME).read_text(
2503 encoding="utf-8"
2504 )
2505 )
2506 angles_path = _write_angles_file(tmp_path, pending_payload["bundle_id"], [
2507 {"id": keep[0],
2508 "podcast": "A mock podcast hook for the renamed topic",
2509 "x_article": "A mock X-article hook for the renamed topic"},
2510 ])
2511
2512 def run_leg3():
2513 return _run_protocol_cli([
2514 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
2515 "--finalize", "--angles", str(angles_path),
2516 ])
2517
2518 first = run_leg3()
2519 assert first.returncode == 0, first.stderr
2520 # A complete brief: topic card, host angle lines, research handoff.
2521 assert "## 1. Renamed Mock Topic" in first.stdout
2522 assert (
2523 "**Podcast angle:** A mock podcast hook for the renamed topic"
2524 in first.stdout
2525 )
2526 assert (
2527 "**X article angle:** A mock X-article hook for the renamed topic"
2528 in first.stdout
2529 )
2530 assert '**Research next:** `/last30days "Renamed Mock Topic"`' in first.stdout
2531 # Mock stays queue-free and deterministic.
2532 assert not (tmp_path / "research.db").exists()
2533 second = run_leg3()
2534 assert second.returncode == 0, second.stderr
2535 assert first.stdout == second.stdout
2536 assert not (tmp_path / "research.db").exists()
2537
2538
2539 # --- U6: engine-side LLM judge removed - discovery is provider-free -----------
2540 # The stage-1 judge and stage-2 angle pass are deleted: no discovery code path
2541 # may resolve a provider runtime or construct a provider client, ever.
2542
2543
2544 def _provider_tripwires() -> list:
2545 """Patches that make ANY provider touch explode: resolve_runtime plus
2546 every client class on the providers module surface."""
2547 def _forbid(label: str):
2548 def _raise(*_args, **_kwargs):
2549 raise AssertionError(f"{label} touched from a discovery code path")
2550 return _raise
2551
2552 return [
2553 mock.patch.object(pipeline.providers, name, new=_forbid(f"providers.{name}"))
2554 for name in (
2555 "resolve_runtime",
2556 "GeminiClient",
2557 "OpenAIClient",
2558 "XAIClient",
2559 "OpenRouterClient",
2560 )
2561 ]
2562
2563
2564 def test_mock_discovery_constructs_no_provider_client(tmp_path, capsys):
2565 """--mock discovery must stay network-clean across the one-shot path AND
2566 all three protocol legs: subprocess tests inherit ambient env keys, so a
2567 single ungated resolve (or a directly constructed client) could let a
2568 --mock run reach the network. It must not emit the loud one-shot
2569 heuristics note either - the note is for real runs, not deliberate mock
2570 runs."""
2571 with contextlib.ExitStack() as stack:
2572 for patcher in _provider_tripwires():
2573 stack.enter_context(patcher)
2574
2575 # One-shot sweep.
2576 report = pipeline.run_discover(
2577 domain="AI agents", config={}, mock=True, as_of_date="2026-07-10",
2578 )
2579 assert report.topics
2580
2581 # Leg 1 (nominate-only) -> leg 2 (real mock enrichment sub-runs) ->
2582 # leg 3 (finalize, no angles file).
2583 bundle_payload = _leg1_bundle_payload(tmp_path)
2584 judgments = {"bundle_id": bundle_payload["bundle_id"], "judgments": []}
2585 assert _run_leg2(tmp_path, judgments) == 0
2586 parser = cli.build_parser()
2587 args, _extra = parser.parse_known_args([
2588 "--discover", "AI agents", "--mock", "--save-dir", str(tmp_path),
2589 "--finalize",
2590 ])
2591 assert cli._run_discover_protocol_leg(args, {}) == 0
2592
2593 assert "deterministic heuristics" not in capsys.readouterr().err
2594
2595
2596 def test_discovery_paths_are_provider_free_at_the_source_level():
2597 """Source-inspection pin (like the enrich ThreadPoolExecutor pin): no
2598 provider resolution is reachable from any discovery entry point, and the
2599 deleted judge module is never referenced by the pipeline."""
2600 for func in (
2601 pipeline.run_discover,
2602 pipeline.run_discover_nominate,
2603 pipeline.run_discover_resume,
2604 pipeline.nominate_topic_pool,
2605 pipeline.nominate_topics,
2606 pipeline.enrich_nominations,
2607 ):
2608 assert "resolve_runtime" not in inspect.getsource(func), func.__name__
2609
2610 needle = "discovery" + "_judge" # split so this pin never matches itself
2611 assert needle not in inspect.getsource(pipeline)
2612
2613
2614 def test_no_engine_judge_references_remain_anywhere():
2615 """Repo-level pin: the engine judge module is deleted and nothing under
2616 skills/ or tests/ references it by name."""
2617 needle = "discovery" + "_judge" # split so this pin never matches itself
2618 assert not (
2619 REPO_ROOT / "skills" / "last30days" / "scripts" / "lib" / f"{needle}.py"
2620 ).exists()
2621 offenders = [
2622 str(path)
2623 for root in ("skills", "tests")
2624 for path in sorted((REPO_ROOT / root).rglob("*.py"))
2625 if needle in path.read_text(encoding="utf-8", errors="ignore")
2626 ]
2627 assert offenders == []
2628
2628 lines PYTHON