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