返回 last30days-skill
test_hosted.py
根目录 / tests / test_hosted.py
1 """Tests for the remote API path (LAST30DAYS_API_KEY + LAST30DAYS_API_BASE).
2
3 Fixtures mirror the remote API contract exactly:
4 POST {base}/search {"query","depth"} -> {"search_id","status"} | clarify payload
5 GET {base}/search?id=<uuid> -> pending|running|complete|error rows
6 401 {"error"} / 402 {"error","requires_credits","balance","needed"} / 429 {"error"}
7 The endpoint is driven entirely through LAST30DAYS_API_BASE; there is no
8 built-in default. All keys/hosts in tests are obvious dummy values (see
9 AGENTS.md security hygiene).
10 """
11
12 import io
13 import json
14 import sys
15 from contextlib import redirect_stderr, redirect_stdout
16 from datetime import datetime
17 from unittest import mock
18
19 import pytest
20
21 import last30days as cli
22 from lib import hosted, http, schema
23
24 TEST_KEY = "sk_live_DUMMY_TEST_KEY_00000"
25 # Neutral placeholder endpoint - no product host. Ends in /api/v1 to mirror the
26 # API-version-root convention the billing-link derivation relies on.
27 TEST_BASE = "https://api.example.test/api/v1"
28 SEARCH_ID = "3f6c1c2e-9f6a-4a55-8f8a-2d1a9b8c7d6e"
29
30 SUBMIT_OK = {"search_id": SEARCH_ID, "status": "running"}
31 POLL_RUNNING = {
32 "id": SEARCH_ID,
33 "status": "running",
34 "stderr": (
35 "[narrate] step=planning queries\n"
36 "[Reddit] fetched 12 threads\n"
37 "[narrate] step=searching sources\n"
38 ),
39 "eta_ms": 45000,
40 }
41 POLL_COMPLETE = {
42 "id": SEARCH_ID,
43 "status": "complete",
44 "synthesis_text": "## What happened\nSynthesized report body.",
45 "raw_markdown": "# Raw markdown\nFull dump.",
46 }
47 CLARIFY_RESPONSE = {
48 "needs_clarification": True,
49 "clarify_class": "ambiguous_entity",
50 "question": "Which 'mercury' do you mean?",
51 "options": ["Mercury the planet", "Mercury the element", "Mercury the band"],
52 "original_query": "mercury",
53 }
54
55 DIAG = {
56 "available_sources": ["grounding"],
57 "providers": {"google": True, "openai": False, "xai": False},
58 "x_backend": None,
59 "bird_installed": False,
60 "bird_authenticated": False,
61 "bird_username": None,
62 "native_web_backend": "brave",
63 }
64
65
66 def make_report(topic: str = "test topic") -> schema.Report:
67 return schema.Report(
68 topic=topic,
69 range_from="2026-06-03",
70 range_to="2026-07-03",
71 generated_at="2026-07-03T00:00:00+00:00",
72 provider_runtime=schema.ProviderRuntime(
73 reasoning_provider="gemini",
74 planner_model="gemini-3.1-flash-lite",
75 rerank_model="gemini-3.1-flash-lite",
76 ),
77 query_plan=schema.QueryPlan(
78 intent="overview",
79 freshness_mode="balanced_recent",
80 cluster_mode="themes",
81 raw_topic=topic,
82 subqueries=[
83 schema.SubQuery(
84 label="primary",
85 search_query=topic.lower(),
86 ranking_query=f"What happened with {topic}?",
87 sources=["grounding"],
88 )
89 ],
90 source_weights={"grounding": 1.0},
91 ),
92 clusters=[],
93 ranked_candidates=[],
94 items_by_source={"grounding": []},
95 errors_by_source={},
96 )
97
98
99 def run_main(argv):
100 stdout, stderr = io.StringIO(), io.StringIO()
101 with mock.patch.object(sys, "argv", ["last30days.py", *argv]):
102 with redirect_stdout(stdout), redirect_stderr(stderr):
103 rc = cli.main()
104 return rc, stdout.getvalue(), stderr.getvalue()
105
106
107 @pytest.fixture(autouse=True)
108 def _clean_env(monkeypatch):
109 monkeypatch.delenv("LAST30DAYS_API_KEY", raising=False)
110 monkeypatch.delenv("LAST30DAYS_API_BASE", raising=False)
111 monkeypatch.delenv("LAST30DAYS_MEMORY_DIR", raising=False)
112
113
114 # ---------------------------------------------------------------------------
115 # Mode selection in the CLI entrypoint
116 # ---------------------------------------------------------------------------
117
118
119 def test_env_unset_runs_local_path_with_no_gateway_http(monkeypatch):
120 """Without LAST30DAYS_API_KEY the local engine runs; the remote client and
121 the API are never touched."""
122
123 def no_hosted(*args, **kwargs): # pragma: no cover - failure path
124 raise AssertionError("remote path must not run when env is unset")
125
126 def no_http(*args, **kwargs): # pragma: no cover - failure path
127 raise AssertionError(f"unexpected HTTP call in local test: {args}")
128
129 monkeypatch.setattr(hosted, "run_hosted", no_hosted)
130 monkeypatch.setattr(http, "request", no_http)
131
132 fake_progress = mock.Mock()
133 with mock.patch.object(cli.env, "get_config", return_value={}), \
134 mock.patch.object(cli.pipeline, "diagnose", return_value=DIAG), \
135 mock.patch.object(cli.pipeline, "run", return_value=make_report()) as pipeline_run, \
136 mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress), \
137 mock.patch.object(cli, "emit_output", return_value="# local rendered"):
138 rc, out, _err = run_main(["test", "topic"])
139
140 assert rc == 0
141 pipeline_run.assert_called_once()
142 assert "# local rendered" in out
143
144
145 def test_env_set_routes_to_remote_path(monkeypatch):
146 # Both vars set -> remote path (KTD-2: key alone no longer activates it).
147 monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
148 monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE)
149 calls = []
150
151 def fake_run_hosted(topic, depth, *, emit, save_dir, save_suffix):
152 calls.append({"topic": topic, "depth": depth, "emit": emit,
153 "save_dir": save_dir, "save_suffix": save_suffix})
154 return 0
155
156 monkeypatch.setattr(hosted, "run_hosted", fake_run_hosted)
157 with mock.patch.object(cli.env, "get_config", return_value={}), \
158 mock.patch.object(cli.pipeline, "run",
159 side_effect=AssertionError("local pipeline must not run")):
160 rc, _out, _err = run_main(["test", "topic"])
161
162 assert rc == 0
163 assert calls == [{
164 "topic": "test topic",
165 "depth": "default",
166 "emit": "compact",
167 "save_dir": None,
168 "save_suffix": "",
169 }]
170
171
172 def test_register_is_forwarded_to_remote_backend(monkeypatch):
173 monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
174 monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE)
175 calls = []
176
177 monkeypatch.setattr(
178 hosted,
179 "run_hosted",
180 lambda topic, depth, **kwargs: calls.append((topic, depth, kwargs)) or 0,
181 )
182 with mock.patch.object(cli.env, "get_config", return_value={}):
183 rc, _out, _err = run_main(
184 ["test", "topic", "--register=creator"]
185 )
186
187 assert rc == 0
188 assert calls == [
189 (
190 "test topic",
191 "default",
192 {
193 "emit": "compact",
194 "save_dir": None,
195 "save_suffix": "",
196 "register": "creator",
197 },
198 )
199 ]
200
201
202 def test_hosted_submit_adds_only_nondefault_register(remote_env, monkeypatch):
203 payloads = []
204 monkeypatch.setattr(
205 hosted.http,
206 "post",
207 lambda _url, *, json_data, **_kwargs: payloads.append(json_data) or SUBMIT_OK,
208 )
209
210 hosted.submit("test topic", "quick")
211 hosted.submit("test topic", "quick", register="dev")
212
213 assert payloads == [
214 {"query": "test topic", "depth": "quick"},
215 {"query": "test topic", "depth": "quick", "register": "dev"},
216 ]
217
218
219 def test_remote_json_requires_raw_profile(monkeypatch):
220 monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
221 monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE)
222 monkeypatch.setattr(
223 hosted,
224 "run_hosted",
225 lambda *args, **kwargs: pytest.fail("remote request should not start"),
226 )
227
228 with mock.patch.object(cli.env, "get_config", return_value={}):
229 rc, _out, err = run_main(["test", "topic", "--emit=json"])
230
231 assert rc == 2
232 assert "remote API backend only supports --json-profile=raw" in err
233
234
235 def test_remote_raw_json_preserves_existing_server_contract(monkeypatch):
236 monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
237 monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE)
238 calls = []
239 monkeypatch.setattr(
240 hosted,
241 "run_hosted",
242 lambda topic, depth, **kwargs: calls.append((topic, depth, kwargs)) or 0,
243 )
244
245 with mock.patch.object(cli.env, "get_config", return_value={}):
246 rc, _out, _err = run_main(
247 ["test", "topic", "--emit=json", "--json-profile=raw"]
248 )
249
250 assert rc == 0
251 assert calls == [
252 (
253 "test topic",
254 "default",
255 {"emit": "json", "save_dir": None, "save_suffix": ""},
256 )
257 ]
258
259
260 @pytest.mark.parametrize(
261 ("flag", "expected_depth"),
262 [(["--quick"], "quick"), ([], "default"), (["--deep"], "deep")],
263 )
264 def test_depth_mapping(monkeypatch, flag, expected_depth):
265 monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
266 monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE)
267 depths = []
268 monkeypatch.setattr(
269 hosted, "run_hosted",
270 lambda topic, depth, **kwargs: depths.append(depth) or 0,
271 )
272 with mock.patch.object(cli.env, "get_config", return_value={}):
273 rc, _out, _err = run_main(["test", "topic", *flag])
274 assert rc == 0
275 assert depths == [expected_depth]
276
277
278 def test_mock_flag_stays_local_even_with_key(monkeypatch):
279 monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
280 monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE)
281
282 def no_hosted(*args, **kwargs): # pragma: no cover - failure path
283 raise AssertionError("remote path must not run with --mock")
284
285 monkeypatch.setattr(hosted, "run_hosted", no_hosted)
286 fake_progress = mock.Mock()
287 with mock.patch.object(cli.env, "get_config", return_value={}), \
288 mock.patch.object(cli.pipeline, "diagnose", return_value=DIAG), \
289 mock.patch.object(cli.pipeline, "run", return_value=make_report()) as pipeline_run, \
290 mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress), \
291 mock.patch.object(cli, "emit_output", return_value="# local rendered"):
292 rc, _out, _err = run_main(["test", "topic", "--mock"])
293 assert rc == 0
294 pipeline_run.assert_called_once()
295
296
297 def test_key_set_but_base_unset_stays_local(monkeypatch):
298 """KTD-2 inertness: with only the key set (no LAST30DAYS_API_BASE), hosted
299 mode does not activate - the local engine runs and no HTTP is attempted.
300 This is the leak-proofing guarantee: a key alone can never phone anywhere."""
301 monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
302 # LAST30DAYS_API_BASE deliberately left unset by the _clean_env fixture.
303
304 def no_hosted(*args, **kwargs): # pragma: no cover - failure path
305 raise AssertionError("remote path must not run without LAST30DAYS_API_BASE")
306
307 def no_http(*args, **kwargs): # pragma: no cover - failure path
308 raise AssertionError(f"unexpected HTTP call when base is unset: {args}")
309
310 monkeypatch.setattr(hosted, "run_hosted", no_hosted)
311 monkeypatch.setattr(http, "request", no_http)
312
313 fake_progress = mock.Mock()
314 with mock.patch.object(cli.env, "get_config", return_value={}), \
315 mock.patch.object(cli.pipeline, "diagnose", return_value=DIAG), \
316 mock.patch.object(cli.pipeline, "run", return_value=make_report()) as pipeline_run, \
317 mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress), \
318 mock.patch.object(cli, "emit_output", return_value="# local rendered"):
319 rc, out, _err = run_main(["test", "topic"])
320
321 assert rc == 0
322 pipeline_run.assert_called_once()
323 assert "# local rendered" in out
324
325
326 # ---------------------------------------------------------------------------
327 # Remote client: submit -> poll -> complete
328 # ---------------------------------------------------------------------------
329
330
331 @pytest.fixture()
332 def remote_env(monkeypatch):
333 monkeypatch.setenv("LAST30DAYS_API_KEY", TEST_KEY)
334 monkeypatch.setenv("LAST30DAYS_API_BASE", TEST_BASE)
335 monkeypatch.setattr(hosted.time, "sleep", lambda _s: None)
336
337
338 def test_happy_path_submit_poll_complete(remote_env, monkeypatch, capsys):
339 posts, gets = [], []
340
341 def fake_post(url, json_data, headers=None, **kwargs):
342 posts.append({"url": url, "json": json_data, "headers": headers})
343 return dict(SUBMIT_OK)
344
345 poll_rows = [dict(POLL_RUNNING), dict(POLL_RUNNING), dict(POLL_COMPLETE)]
346
347 def fake_get(url, headers=None, params=None, **kwargs):
348 gets.append({"url": url, "headers": headers, "params": params})
349 return poll_rows.pop(0)
350
351 monkeypatch.setattr(hosted.http, "post", fake_post)
352 monkeypatch.setattr(hosted.http, "get", fake_get)
353
354 rc = hosted.run_hosted("test topic", "default", emit="compact",
355 save_dir=None, save_suffix="")
356 out = capsys.readouterr().out
357
358 assert rc == 0
359 # Contract: submit
360 assert posts == [{
361 "url": f"{TEST_BASE}/search",
362 "json": {"query": "test topic", "depth": "default"},
363 "headers": {"Authorization": f"Bearer {TEST_KEY}"},
364 }]
365 # Contract: poll same auth, id param
366 assert all(g["url"] == f"{TEST_BASE}/search" for g in gets)
367 assert all(g["params"] == {"id": SEARCH_ID} for g in gets)
368 assert all(g["headers"] == {"Authorization": f"Bearer {TEST_KEY}"} for g in gets)
369 assert len(gets) == 3
370 # Synthesis rendered on stdout
371 assert "Synthesized report body." in out
372
373
374 def test_happy_path_narration_printed_once_and_no_key_echo(remote_env, monkeypatch, capsys):
375 monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
376 poll_rows = [dict(POLL_RUNNING), dict(POLL_RUNNING), dict(POLL_COMPLETE)]
377 monkeypatch.setattr(hosted.http, "get", lambda *a, **k: poll_rows.pop(0))
378
379 rc = hosted.run_hosted("test topic", "default", emit="compact",
380 save_dir=None, save_suffix="")
381 captured = capsys.readouterr()
382
383 assert rc == 0
384 # Each narration step printed exactly once even though the stderr blob
385 # was returned twice by consecutive polls.
386 assert captured.err.count("[narrate] step=planning queries") == 1
387 assert captured.err.count("[narrate] step=searching sources") == 1
388 # Progress line with elapsed/eta shape
389 assert "eta" in captured.err
390 # The API key never appears anywhere in output.
391 assert TEST_KEY not in captured.out
392 assert TEST_KEY not in captured.err
393
394
395 def test_save_dir_writes_raw_markdown(remote_env, monkeypatch, capsys, tmp_path):
396 monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
397 poll_rows = [dict(POLL_COMPLETE)]
398 monkeypatch.setattr(hosted.http, "get", lambda *a, **k: poll_rows.pop(0))
399
400 rc = hosted.run_hosted("Test Topic!", "default", emit="compact",
401 save_dir=str(tmp_path), save_suffix="")
402 captured = capsys.readouterr()
403
404 assert rc == 0
405 saved = tmp_path / "test-topic-raw.md"
406 assert saved.exists()
407 assert "# Raw markdown" in saved.read_text(encoding="utf-8")
408 assert "Saved output to" in captured.err
409 assert TEST_KEY not in captured.err
410
411
412 def test_save_dir_uses_unique_dated_fallback(remote_env, monkeypatch, capsys, tmp_path):
413 monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
414 poll_rows = [dict(POLL_COMPLETE)]
415 monkeypatch.setattr(hosted.http, "get", lambda *a, **k: poll_rows.pop(0))
416 today = datetime.now().strftime("%Y-%m-%d")
417 base = tmp_path / "test-topic-raw.md"
418 dated = tmp_path / f"test-topic-raw-{today}.md"
419 base.write_text("base content", encoding="utf-8")
420 dated.write_text("dated content", encoding="utf-8")
421
422 rc = hosted.run_hosted("Test Topic!", "default", emit="compact",
423 save_dir=str(tmp_path), save_suffix="")
424 captured = capsys.readouterr()
425
426 saved = tmp_path / f"test-topic-raw-{today}-1.md"
427 assert rc == 0
428 assert saved.exists()
429 assert "# Raw markdown" in saved.read_text(encoding="utf-8")
430 assert base.read_text(encoding="utf-8") == "base content"
431 assert dated.read_text(encoding="utf-8") == "dated content"
432 assert f"Saved output to {saved.resolve()}" in captured.err
433 assert TEST_KEY not in captured.err
434
435
436 def test_api_base_override(remote_env, monkeypatch, capsys):
437 monkeypatch.setenv("LAST30DAYS_API_BASE", "https://staging.example.dev/api/v1/")
438 urls = []
439
440 def fake_post(url, json_data, headers=None, **kwargs):
441 urls.append(url)
442 return dict(SUBMIT_OK)
443
444 monkeypatch.setattr(hosted.http, "post", fake_post)
445 monkeypatch.setattr(hosted.http, "get", lambda *a, **k: dict(POLL_COMPLETE))
446
447 rc = hosted.run_hosted("test topic", "quick", emit="compact",
448 save_dir=None, save_suffix="")
449 capsys.readouterr()
450 assert rc == 0
451 assert urls == ["https://staging.example.dev/api/v1/search"]
452
453
454 # ---------------------------------------------------------------------------
455 # Error handling
456 # ---------------------------------------------------------------------------
457
458
459 def test_401_invalid_or_revoked_key(remote_env, monkeypatch, capsys):
460 def fake_post(*a, **k):
461 raise http.HTTPError("HTTP 401: Unauthorized", 401,
462 json.dumps({"error": "Invalid API key"}))
463
464 monkeypatch.setattr(hosted.http, "post", fake_post)
465 rc = hosted.run_hosted("test topic", "default", emit="compact",
466 save_dir=None, save_suffix="")
467 captured = capsys.readouterr()
468 assert rc == 1
469 assert "invalid or revoked" in captured.err.lower()
470 assert TEST_KEY not in captured.err
471
472
473 def test_402_shows_balance_needed_and_billing_url(remote_env, monkeypatch, capsys):
474 body = {"error": "Insufficient credits", "requires_credits": True,
475 "balance": 40, "needed": 200}
476
477 def fake_post(*a, **k):
478 raise http.HTTPError("HTTP 402: Payment Required", 402, json.dumps(body))
479
480 monkeypatch.setattr(hosted.http, "post", fake_post)
481 rc = hosted.run_hosted("test topic", "deep", emit="compact",
482 save_dir=None, save_suffix="")
483 captured = capsys.readouterr()
484 assert rc == 1
485 # Balance and needed shown verbatim from the API response.
486 assert "40" in captured.err
487 assert "200" in captured.err
488 # Billing link is derived from the configured base (base minus /api/v1,
489 # plus /dashboard/billing) - never hardcoded.
490 assert "https://api.example.test/dashboard/billing" in captured.err
491 assert TEST_KEY not in captured.err
492
493
494 def test_429_rate_limited(remote_env, monkeypatch, capsys):
495 def fake_post(*a, **k):
496 raise http.HTTPError("HTTP 429: Too Many Requests", 429,
497 json.dumps({"error": "Rate limit exceeded"}))
498
499 monkeypatch.setattr(hosted.http, "post", fake_post)
500 rc = hosted.run_hosted("test topic", "default", emit="compact",
501 save_dir=None, save_suffix="")
502 captured = capsys.readouterr()
503 assert rc == 1
504 assert "rate limit" in captured.err.lower()
505
506
507 def test_clarify_response_prints_question_options_distinct_exit(remote_env, monkeypatch, capsys):
508 monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(CLARIFY_RESPONSE))
509
510 def no_get(*a, **k): # pragma: no cover - failure path
511 raise AssertionError("must not poll on clarify")
512
513 monkeypatch.setattr(hosted.http, "get", no_get)
514 rc = hosted.run_hosted("mercury", "default", emit="compact",
515 save_dir=None, save_suffix="")
516 captured = capsys.readouterr()
517 assert rc == hosted.EXIT_CLARIFY
518 assert rc not in (0, 1)
519 assert "Which 'mercury' do you mean?" in captured.err
520 assert "Mercury the planet" in captured.err
521 assert "Mercury the band" in captured.err
522 assert "re-run" in captured.err.lower()
523
524
525 def test_error_status_run_prints_server_message(remote_env, monkeypatch, capsys):
526 monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
527 error_row = {"id": SEARCH_ID, "status": "error",
528 "error": "Synthesis failed upstream"}
529 monkeypatch.setattr(hosted.http, "get", lambda *a, **k: dict(error_row))
530 rc = hosted.run_hosted("test topic", "default", emit="compact",
531 save_dir=None, save_suffix="")
532 captured = capsys.readouterr()
533 assert rc == 1
534 assert "Synthesis failed upstream" in captured.err
535
536
537 def test_network_timeout_mid_poll_retries_get(remote_env, monkeypatch, capsys):
538 monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
539 attempts = []
540
541 def flaky_get(*a, **k):
542 attempts.append(1)
543 if len(attempts) < 3:
544 raise http.HTTPError("Connection error: TimeoutError: timed out")
545 return dict(POLL_COMPLETE)
546
547 monkeypatch.setattr(hosted.http, "get", flaky_get)
548 rc = hosted.run_hosted("test topic", "default", emit="compact",
549 save_dir=None, save_suffix="")
550 captured = capsys.readouterr()
551 assert rc == 0
552 assert len(attempts) == 3
553 assert "Synthesized report body." in captured.out
554
555
556 def test_persistent_network_failure_gives_up_with_message(remote_env, monkeypatch, capsys):
557 monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
558
559 def dead_get(*a, **k):
560 raise http.HTTPError("Connection error: TimeoutError: timed out")
561
562 monkeypatch.setattr(hosted.http, "get", dead_get)
563 rc = hosted.run_hosted("test topic", "default", emit="compact",
564 save_dir=None, save_suffix="")
565 captured = capsys.readouterr()
566 assert rc == 1
567 assert "poll" in captured.err.lower()
568 assert TEST_KEY not in captured.err
569
570
571 def test_emit_json_prints_terminal_row_without_stderr(remote_env, monkeypatch, capsys):
572 monkeypatch.setattr(hosted.http, "post", lambda *a, **k: dict(SUBMIT_OK))
573 monkeypatch.setattr(hosted.http, "get", lambda *a, **k: dict(POLL_COMPLETE))
574 rc = hosted.run_hosted("test topic", "default", emit="json",
575 save_dir=None, save_suffix="")
576 captured = capsys.readouterr()
577 assert rc == 0
578 payload = json.loads(captured.out)
579 assert payload["status"] == "complete"
580 assert payload["synthesis_text"].startswith("## What happened")
581 assert payload["raw_markdown"].startswith("# Raw markdown")
582 assert "stderr" not in payload
583 assert TEST_KEY not in captured.out
584
584 lines PYTHON