@openhands/extensions 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.claude-plugin/marketplace.json +24 -0
  2. package/.codex-plugin/marketplace.json +24 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +24 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +9 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/bundle-index.js +15 -15
  22. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  23. package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
  24. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
  25. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  26. package/automations/catalog/news-digest/manifest.json +2 -2
  27. package/index.d.ts +2 -0
  28. package/index.js +1 -0
  29. package/marketplaces/openhands-extensions.json +24 -0
  30. package/package.json +9 -1
  31. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  32. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  33. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  34. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  35. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  36. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  37. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  38. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  39. package/plugins/runtime-api-configs/README.md +59 -0
  40. package/plugins/runtime-api-configs/SKILL.md +240 -0
  41. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  42. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  43. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  44. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +67 -15
  45. package/pyproject.toml +1 -1
  46. package/python/openhands_extensions/_version.py +1 -1
  47. package/scripts/check_plugin_resources.py +116 -0
  48. package/scripts/sync_extensions.py +32 -5
  49. package/skills/canvas-extension-api/SKILL.md +34 -3
  50. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  51. package/skills/canvas-extension-api/references/connections.md +24 -0
  52. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  53. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  54. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  55. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  56. package/skills/github/scripts/agent_conversation.py +223 -43
  57. package/skills/github/scripts/github_client.py +67 -15
  58. package/skills/github-agents-md-maintainer/README.md +7 -0
  59. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  60. package/skills/github-agents-md-maintainer/scripts/github_client.py +67 -15
  61. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  62. package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
  63. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  64. package/skills/github-issue-to-pr/scripts/github_client.py +67 -15
  65. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  66. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  67. package/skills/github-issue-triage/scripts/github_client.py +67 -15
  68. package/skills/github-pr-reviewer/README.md +5 -4
  69. package/skills/github-pr-reviewer/SKILL.md +8 -17
  70. package/skills/github-pr-reviewer/references/state-schema.md +0 -15
  71. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  72. package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
  73. package/skills/github-pr-reviewer/scripts/worker.py +131 -108
  74. package/skills/github-repo-monitor/scripts/github_client.py +67 -15
  75. package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
  76. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
  77. package/skills/gitlab-issue-to-mr/README.md +12 -0
  78. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  79. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  80. package/skills/incident-retrospective/SKILL.md +5 -5
  81. package/skills/index.js +23 -14
  82. package/skills/jira-issue-to-pr/README.md +12 -4
  83. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  84. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  85. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  86. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  87. package/skills/linear-triage/SKILL.md +5 -5
  88. package/skills/news-digest/README.md +6 -0
  89. package/skills/news-digest/SKILL.md +19 -9
  90. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  91. package/skills/news-digest/scripts/main.py +133 -15
  92. package/skills/openhands-api/README.md +1 -1
  93. package/skills/openhands-api/SKILL.md +3 -3
  94. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  95. package/skills/openhands-automation/SKILL.md +66 -4
  96. package/skills/openhands-automation/references/custom-automation.md +21 -7
  97. package/skills/research-brief/SKILL.md +5 -5
  98. package/skills/slack-standup-digest/SKILL.md +5 -5
  99. package/skills/ticket-to-code-change/SKILL.md +6 -3
  100. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  101. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  102. package/skills/use-jev/.plugin/plugin.json +8 -0
  103. package/skills/use-jev/README.md +66 -0
  104. package/skills/use-jev/SKILL.md +66 -0
  105. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  106. package/tests/test_agent_conversation_dispatch.py +212 -0
  107. package/tests/test_apps_catalog.py +26 -0
  108. package/tests/test_automation_setup.py +44 -0
  109. package/tests/test_check_pr_description.py +89 -6
  110. package/tests/test_github_reviewer_delivery.py +254 -118
  111. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  112. package/tests/test_jira_issue_to_pr.py +312 -0
  113. package/tests/test_jira_setup_webhook.py +309 -0
  114. package/tests/test_openhands_api_python.py +37 -0
  115. package/tests/test_skill_plugin_loading.py +25 -15
  116. package/tests/test_sync_extensions.py +102 -0
  117. package/tests/test_vscode_app.py +33 -0
  118. package/tests/vscode-app.test.mjs +43 -0
@@ -94,6 +94,9 @@ def test_new_subject_uses_selected_profile_and_persists_mapping(monkeypatch):
94
94
  "subject": "repo:issue:7",
95
95
  "conversation_id": result["conversation_id"],
96
96
  "delivery": "revision-1",
97
+ # A caller with no revision identity records an empty head, which keeps
98
+ # the dedupe keyed on `delivery` alone.
99
+ "head": "",
97
100
  }
98
101
 
99
102
 
@@ -177,6 +180,89 @@ def test_same_delivery_resumes_only_inactive_conversation(
177
180
  conversation.run.assert_not_called()
178
181
 
179
182
 
183
+ def test_new_delivery_on_a_running_same_head_is_not_a_second_review(monkeypatch):
184
+ """A fresh trigger for a head already under review must not start a review.
185
+
186
+ This is the duplicate the trigger-keyed delivery alone allowed: a second
187
+ review request (or a label re-applied after the bot's own handoff) produces a
188
+ new delivery key, which would send a new turn and publish a second review of
189
+ the head an in-flight conversation is already reviewing.
190
+ """
191
+ state = {
192
+ _state_key("repo:pr:9"): {
193
+ "subject": "repo:pr:9",
194
+ "conversation_id": "22222222-2222-4222-8222-222222222222",
195
+ "delivery": "42:head-2",
196
+ "head": "head-2",
197
+ }
198
+ }
199
+ _fake_kv(monkeypatch, state)
200
+ monkeypatch.setattr(agent_conversation, "_register_tools", lambda: None)
201
+ workspace = MagicMock()
202
+ workspace.__enter__.return_value = workspace
203
+ workspace.get_secrets.return_value = {}
204
+ monkeypatch.setattr(
205
+ agent_conversation, "RemoteWorkspace", lambda **kwargs: workspace
206
+ )
207
+ conversation = MagicMock()
208
+ conversation.state.execution_status = ConversationExecutionStatus.RUNNING
209
+ monkeypatch.setattr(
210
+ agent_conversation.RemoteConversation,
211
+ "attach",
212
+ MagicMock(return_value=conversation),
213
+ )
214
+
215
+ with _dispatcher(monkeypatch) as dispatcher:
216
+ result = dispatcher.deliver("repo:pr:9", "43:head-2", "again", head="head-2")
217
+
218
+ assert result["disposition"] == "in_progress"
219
+ conversation.send_message.assert_not_called()
220
+ conversation.run.assert_not_called()
221
+ # The revision the live turn is working on is left recorded, not overwritten
222
+ # by the trigger that arrived beside it.
223
+ assert state[_state_key("repo:pr:9")]["delivery"] == "42:head-2"
224
+
225
+
226
+ def test_new_delivery_on_a_running_new_head_still_sends_a_turn(monkeypatch):
227
+ """A moved head is new work, so the running conversation gets the new turn."""
228
+ state = {
229
+ _state_key("repo:pr:9"): {
230
+ "subject": "repo:pr:9",
231
+ "conversation_id": "22222222-2222-4222-8222-222222222222",
232
+ "delivery": "42:head-1",
233
+ "head": "head-1",
234
+ }
235
+ }
236
+ _fake_kv(monkeypatch, state)
237
+ monkeypatch.setattr(agent_conversation, "_register_tools", lambda: None)
238
+ workspace = MagicMock()
239
+ workspace.__enter__.return_value = workspace
240
+ workspace.get_secrets.return_value = {}
241
+ monkeypatch.setattr(
242
+ agent_conversation, "RemoteWorkspace", lambda **kwargs: workspace
243
+ )
244
+ conversation = MagicMock()
245
+ conversation.state.execution_status = ConversationExecutionStatus.RUNNING
246
+ monkeypatch.setattr(
247
+ agent_conversation.RemoteConversation,
248
+ "attach",
249
+ MagicMock(return_value=conversation),
250
+ )
251
+
252
+ with _dispatcher(monkeypatch) as dispatcher:
253
+ result = dispatcher.deliver("repo:pr:9", "43:head-2", "next", head="head-2")
254
+
255
+ assert result["disposition"] == "resumed"
256
+ conversation.send_message.assert_called_once_with("next")
257
+ conversation.run.assert_called_once_with(blocking=False)
258
+ assert state[_state_key("repo:pr:9")] == {
259
+ "subject": "repo:pr:9",
260
+ "conversation_id": result["conversation_id"],
261
+ "delivery": "43:head-2",
262
+ "head": "head-2",
263
+ }
264
+
265
+
180
266
  def test_subjects_use_independent_kv_records(monkeypatch):
181
267
  state = {}
182
268
  _fake_kv(monkeypatch, state)
@@ -209,3 +295,129 @@ def test_subjects_use_independent_kv_records(monkeypatch):
209
295
  _state_key("repo:issue:7"),
210
296
  _state_key("repo:issue:8"),
211
297
  }
298
+
299
+
300
+ # --- OpenHands Cloud and Enterprise: conversations go through the OpenHands API
301
+
302
+
303
+ class _FakeCloud:
304
+ """The OpenHands API's conversations, as the dispatcher uses them."""
305
+
306
+ def __init__(self, conversation=None):
307
+ self.conversation = conversation
308
+ self.started = []
309
+ self.sent = []
310
+
311
+ def get(self, conversation_id):
312
+ return self.conversation
313
+
314
+ def start(self, conversation_id, profile_id, title, prompt):
315
+ self.started.append({"id": str(conversation_id), "profile": str(profile_id)})
316
+
317
+ def send(self, conversation, prompt):
318
+ self.sent.append(prompt)
319
+
320
+ def run(self, conversation):
321
+ pass
322
+
323
+
324
+ def _cloud_dispatcher(monkeypatch, cloud):
325
+ monkeypatch.delenv("AGENT_SERVER_URL", raising=False)
326
+ monkeypatch.setenv("SESSION_API_KEY", "session")
327
+ monkeypatch.setenv(
328
+ "AUTOMATION_AGENT_PROFILE_ID", "11111111-1111-4111-8111-111111111111"
329
+ )
330
+ monkeypatch.setenv(
331
+ "AUTOMATION_EVENT_PAYLOAD", json.dumps({"automation_id": "automation-1"})
332
+ )
333
+ monkeypatch.setattr(agent_conversation, "CloudConversations", lambda: cloud)
334
+ return agent_conversation.AgentConversationDispatcher()
335
+
336
+
337
+ def test_cloud_run_starts_a_new_subject_with_the_selected_profile(monkeypatch):
338
+ # Arrange
339
+ state = {}
340
+ _fake_kv(monkeypatch, state)
341
+ cloud = _FakeCloud()
342
+
343
+ # Act
344
+ with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
345
+ result = dispatcher.deliver("repo:pr:7", "revision-1", "review it")
346
+
347
+ # Assert
348
+ assert result["disposition"] == "created"
349
+ [started] = cloud.started
350
+ assert started["profile"] == "11111111-1111-4111-8111-111111111111"
351
+ assert state[_state_key("repo:pr:7")]["conversation_id"] == started["id"]
352
+
353
+
354
+ def test_cloud_run_does_not_start_a_second_conversation_while_one_is_starting(
355
+ monkeypatch,
356
+ ):
357
+ # Arrange - the first delivery's conversation is not listed by the API yet
358
+ state = {}
359
+ _fake_kv(monkeypatch, state)
360
+ cloud = _FakeCloud()
361
+ with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
362
+ dispatcher.deliver("repo:pr:7", "revision-1", "review it", head="sha-1")
363
+ recorded = dict(state[_state_key("repo:pr:7")])
364
+
365
+ # Act - a new revision arrives before it is
366
+ result = dispatcher.deliver(
367
+ "repo:pr:7", "revision-2", "review again", head="sha-2"
368
+ )
369
+
370
+ # Assert - nothing is started, and the record is left for the next trigger
371
+ assert result["disposition"] == "in_progress"
372
+ assert len(cloud.started) == 1
373
+ assert state[_state_key("repo:pr:7")] == recorded
374
+
375
+
376
+ def test_cloud_run_sends_a_new_revision_to_the_subjects_conversation(monkeypatch):
377
+ # Arrange
378
+ state = {
379
+ _state_key("repo:pr:7"): {
380
+ "conversation_id": "22222222-2222-4222-8222-222222222222",
381
+ "delivery": "revision-1",
382
+ "head": "sha-1",
383
+ }
384
+ }
385
+ _fake_kv(monkeypatch, state)
386
+ cloud = _FakeCloud({"sandbox_status": "RUNNING", "execution_status": "idle"})
387
+
388
+ # Act
389
+ with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
390
+ result = dispatcher.deliver(
391
+ "repo:pr:7", "revision-2", "review again", head="sha-2"
392
+ )
393
+
394
+ # Assert
395
+ assert result["disposition"] == "resumed"
396
+ assert cloud.sent == ["review again"]
397
+ assert cloud.started == []
398
+
399
+
400
+ def test_cloud_run_replaces_a_conversation_whose_sandbox_is_gone(monkeypatch):
401
+ # Arrange
402
+ gone = "22222222-2222-4222-8222-222222222222"
403
+ state = {
404
+ _state_key("repo:pr:7"): {
405
+ "conversation_id": gone,
406
+ "delivery": "revision-1",
407
+ "head": "sha-1",
408
+ }
409
+ }
410
+ _fake_kv(monkeypatch, state)
411
+ cloud = _FakeCloud({"sandbox_status": "MISSING", "execution_status": None})
412
+
413
+ # Act
414
+ with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
415
+ result = dispatcher.deliver(
416
+ "repo:pr:7", "revision-2", "review again", head="sha-2"
417
+ )
418
+
419
+ # Assert - the API does not start an id twice, so the subject gets a new one
420
+ assert result["disposition"] == "created"
421
+ [started] = cloud.started
422
+ assert started["id"] != gone
423
+ assert state[_state_key("repo:pr:7")]["conversation_id"] == started["id"]
@@ -0,0 +1,26 @@
1
+ import json
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ ROOT = Path(__file__).resolve().parents[1]
6
+
7
+
8
+ def test_apps_catalog_is_valid_and_package_export_matches():
9
+ entries = json.loads((ROOT / "apps/catalog.json").read_text())
10
+ assert isinstance(entries, list)
11
+ assert len({entry["id"] for entry in entries}) == len(entries)
12
+ for entry in entries:
13
+ assert set(entry) == {"id", "name", "description", "source", "manifest", "beta"}
14
+ assert entry["id"] and entry["source"].startswith("./apps/")
15
+ assert entry["manifest"].endswith("/canvas-extension.json")
16
+ assert entry["beta"] is True
17
+ subprocess.run(
18
+ [
19
+ "node",
20
+ "--input-type=module",
21
+ "-e",
22
+ "import { APPS_CATALOG } from './index.js'; if (!Array.isArray(APPS_CATALOG)) process.exit(1)",
23
+ ],
24
+ cwd=ROOT,
25
+ check=True,
26
+ )
@@ -625,6 +625,25 @@ def _iter_strings(node):
625
625
  yield node
626
626
 
627
627
 
628
+ def _scenario_template_versions(bundle: dict) -> set[str]:
629
+ """Every template version a fixture pins, wherever it appears."""
630
+ versions = set()
631
+
632
+ def walk(node) -> None:
633
+ if isinstance(node, dict):
634
+ template = node.get("template")
635
+ if isinstance(template, dict) and "version" in template:
636
+ versions.add(template["version"])
637
+ for value in node.values():
638
+ walk(value)
639
+ elif isinstance(node, list):
640
+ for item in node:
641
+ walk(item)
642
+
643
+ walk(bundle["scenarios"])
644
+ return versions
645
+
646
+
628
647
  def _scenarios(kind: str):
629
648
  """Every fixture scenario carrying the given block, as test params."""
630
649
  for path in sorted(FIXTURE_DIR.glob("*.json")):
@@ -787,6 +806,31 @@ def test_multi_action_fixture_scenarios_name_the_selected_variant(
787
806
  assert scenario.get("selectedAction") in actions, scenario["id"]
788
807
 
789
808
 
809
+ @pytest.mark.parametrize("fixture_path", list(_fixture_bundles()))
810
+ def test_fixture_template_versions_match_the_entry_to_pin_the_bump(
811
+ fixture_path: Path,
812
+ ) -> None:
813
+ """A fixture pins the template version the host will send, so it moves with
814
+ the entry. Pinning a stale version passes until the version is bumped, and
815
+ then every scenario in the fixture fails at once - the bump, not the
816
+ behavior, is what changed."""
817
+ bundle = _load(fixture_path)
818
+ entry = _entry_for(bundle)
819
+
820
+ expected = set()
821
+ if "version" in entry:
822
+ expected.add(entry["version"])
823
+ if "version" in entry.get("setup", {}).get("bundle", {}):
824
+ expected.add(entry["setup"]["bundle"]["version"])
825
+
826
+ pinned = _scenario_template_versions(bundle)
827
+
828
+ assert pinned <= expected, (
829
+ f"{fixture_path.stem}: fixture pins template version(s) {sorted(pinned)} "
830
+ f"but {entry['id']} declares {sorted(expected)}"
831
+ )
832
+
833
+
790
834
  @pytest.mark.parametrize("entry_path", list(_setup_paths()))
791
835
  def test_the_declared_features_match_the_archetype(entry_path: Path) -> None:
792
836
  """A bundle needs a deployment that runs a client-supplied tarball; a prompt
@@ -6,10 +6,9 @@ import sys
6
6
  from pathlib import Path
7
7
 
8
8
 
9
- def _load_prod_module():
9
+ def _load(name: str, script_name: str):
10
10
  repo_root = Path(__file__).resolve().parents[1]
11
- script_path = repo_root / ".github" / "scripts" / "check_pr_description.py"
12
- name = "check_pr_description"
11
+ script_path = repo_root / ".github" / "scripts" / script_name
13
12
  spec = importlib.util.spec_from_file_location(name, script_path)
14
13
  assert spec and spec.loader
15
14
  mod = importlib.util.module_from_spec(spec)
@@ -18,9 +17,12 @@ def _load_prod_module():
18
17
  return mod
19
18
 
20
19
 
21
- _prod = _load_prod_module()
20
+ # `check_pr_description` resolves its sibling `markdown_sections` itself, so no
21
+ # preloading is needed here and this file genuinely exercises that contract.
22
+ _prod = _load("check_pr_description", "check_pr_description.py")
22
23
  validate_pr_body = _prod.validate_pr_body
23
24
  body_from_event = _prod.body_from_event
25
+ extract_human_note = _prod.extract_human_note
24
26
  extract_linked_issue_numbers = _prod.extract_linked_issue_numbers
25
27
  validate_linked_issue_ready = _prod.validate_linked_issue_ready
26
28
  fetch_issue_details = _prod.fetch_issue_details
@@ -28,9 +30,11 @@ fetch_issue_details = _prod.fetch_issue_details
28
30
 
29
31
  VALID_BODY = """<!-- Keep this PR as draft until it is ready for review. -->
30
32
 
31
- <!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
33
+ HUMAN:
32
34
 
33
- - [ ] A human has tested these changes.
35
+ I checked this change and it does what the PR says.
36
+
37
+ AGENT:
34
38
 
35
39
  ---
36
40
 
@@ -64,6 +68,85 @@ def test_valid_pr_body_passes():
64
68
  assert validate_pr_body(VALID_BODY) == []
65
69
 
66
70
 
71
+ def test_first_visible_line_must_be_human_marker():
72
+ body = VALID_BODY.replace("HUMAN:", "## Why\n\nSomething.\n\nHUMAN:", 1)
73
+
74
+ errors = validate_pr_body(body)
75
+
76
+ assert any("first visible line" in e for e in errors)
77
+
78
+
79
+ def test_human_note_must_clear_the_minimum_length():
80
+ body = VALID_BODY.replace(
81
+ "I checked this change and it does what the PR says.", "Looks good."
82
+ )
83
+
84
+ errors = validate_pr_body(body)
85
+
86
+ assert any("human-written note" in e for e in errors)
87
+
88
+
89
+ def test_missing_human_section_is_reported():
90
+ body = VALID_BODY.replace("HUMAN:", "", 1)
91
+
92
+ errors = validate_pr_body(body)
93
+
94
+ assert any("first visible line" in e for e in errors)
95
+ assert any("human-written note" in e for e in errors)
96
+
97
+
98
+ def test_agent_marker_must_be_kept():
99
+ body = VALID_BODY.replace("AGENT:", "", 1)
100
+
101
+ errors = validate_pr_body(body)
102
+
103
+ assert any("AGENT:" in e for e in errors)
104
+
105
+
106
+ def test_quoting_the_template_markers_in_a_fence_does_not_satisfy_the_gate():
107
+ # A body whose only HUMAN:/AGENT: markers are quoted inside a code fence has
108
+ # no real human attestation: the markers must appear outside fenced code.
109
+ body = VALID_BODY.replace(
110
+ "HUMAN:\n\nI checked this change and it does what the PR says.\n\nAGENT:",
111
+ "```text\nHUMAN:\n\nfill this in yourself\n\nAGENT:\n```",
112
+ )
113
+
114
+ errors = validate_pr_body(body)
115
+
116
+ assert any("first visible line" in e for e in errors)
117
+ assert any("human-written note" in e for e in errors)
118
+
119
+
120
+ def test_human_note_stops_at_the_agent_marker():
121
+ # Text after AGENT: must not count toward the human note.
122
+ body = VALID_BODY.replace(
123
+ "I checked this change and it does what the PR says.",
124
+ "ok\n\nAGENT:\n\n" + "agent padding " * 5,
125
+ )
126
+
127
+ assert extract_human_note(body) == "ok"
128
+ assert any("human-written note" in e for e in validate_pr_body(body))
129
+
130
+
131
+ def test_human_note_and_sections_survive_crlf_bodies():
132
+ body = VALID_BODY.replace("\n", "\r\n")
133
+
134
+ assert validate_pr_body(body) == []
135
+
136
+
137
+ def test_template_fields_must_be_outside_fenced_code_blocks():
138
+ # Quoting the template's headings in a fence must not satisfy the sections.
139
+ body = (
140
+ "HUMAN:\n\nI reviewed this change carefully and it works as described.\n\n"
141
+ "AGENT:\n\n```markdown\n## Why\n\nx\n\n## Summary\n\n- y\n\n"
142
+ "## How to Test\n\nz\n```\n"
143
+ )
144
+
145
+ errors = validate_pr_body(body)
146
+
147
+ assert any("Keep the `## Why`" in e for e in errors)
148
+
149
+
67
150
  def test_required_template_fields_must_be_present_and_filled():
68
151
  how_to_test = "## How to Test\n\nRun `uv run --group test pytest tests/`."
69
152
  body = VALID_BODY.replace(how_to_test, "## How to Test\n\n<!-- TODO -->")