@openhands/extensions 0.24.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 (133) hide show
  1. package/.claude-plugin/marketplace.json +30 -0
  2. package/.codex-plugin/marketplace.json +30 -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 +30 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +10 -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 +18 -14
  22. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  23. package/automations/catalog/github-pr-reviewer/manifest.json +19 -8
  24. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +71 -0
  25. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  26. package/automations/catalog/news-digest/manifest.json +2 -2
  27. package/automations/catalog-index.js +2 -0
  28. package/index.d.ts +2 -0
  29. package/index.js +1 -0
  30. package/marketplaces/openhands-extensions.json +30 -0
  31. package/package.json +9 -1
  32. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  33. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  34. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  35. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  36. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  37. package/plugins/openhands/skills/openhands-sdk/SKILL.md +1 -0
  38. package/plugins/pr-review/skills/github-pr-review/SKILL.md +12 -2
  39. package/plugins/qa-changes/skills/github-pr-review/SKILL.md +12 -2
  40. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  41. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  42. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  43. package/plugins/runtime-api-configs/README.md +59 -0
  44. package/plugins/runtime-api-configs/SKILL.md +240 -0
  45. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  46. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  47. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  48. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +183 -15
  49. package/pyproject.toml +1 -1
  50. package/python/openhands_extensions/_version.py +1 -1
  51. package/scripts/check_plugin_resources.py +116 -0
  52. package/scripts/sync_extensions.py +32 -5
  53. package/skills/canvas-extension-api/SKILL.md +34 -3
  54. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  55. package/skills/canvas-extension-api/references/connections.md +24 -0
  56. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  57. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  58. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  59. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  60. package/skills/github/scripts/agent_conversation.py +223 -43
  61. package/skills/github/scripts/github_client.py +183 -15
  62. package/skills/github-agents-md-maintainer/README.md +7 -0
  63. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  64. package/skills/github-agents-md-maintainer/scripts/github_client.py +183 -15
  65. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  66. package/skills/github-delivery-watchdog/scripts/github_client.py +183 -15
  67. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  68. package/skills/github-issue-to-pr/scripts/github_client.py +183 -15
  69. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  70. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  71. package/skills/github-issue-triage/scripts/github_client.py +183 -15
  72. package/skills/github-pr-review/SKILL.md +12 -2
  73. package/skills/github-pr-reviewer/README.md +36 -0
  74. package/skills/github-pr-reviewer/SKILL.md +147 -4
  75. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  76. package/skills/github-pr-reviewer/scripts/github_client.py +183 -15
  77. package/skills/github-pr-reviewer/scripts/main.py +17 -1
  78. package/skills/github-pr-reviewer/scripts/worker.py +842 -57
  79. package/skills/github-repo-monitor/scripts/github_client.py +183 -15
  80. package/skills/github-repo-monitor/scripts/main.py +32 -1
  81. package/skills/github-stale-ci-pr-closer/.claude-plugin/plugin.json +9 -0
  82. package/skills/github-stale-ci-pr-closer/.codex-plugin/plugin.json +9 -0
  83. package/skills/github-stale-ci-pr-closer/.plugin/plugin.json +9 -0
  84. package/skills/github-stale-ci-pr-closer/README.md +9 -0
  85. package/skills/github-stale-ci-pr-closer/SKILL.md +24 -0
  86. package/skills/github-stale-ci-pr-closer/commands/github-stale-ci-pr-closer.md +8 -0
  87. package/skills/github-stale-ci-pr-closer/scripts/worker.py +508 -0
  88. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +353 -0
  89. package/skills/gitlab-issue-to-mr/README.md +12 -0
  90. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  91. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  92. package/skills/incident-retrospective/SKILL.md +5 -5
  93. package/skills/index.js +34 -16
  94. package/skills/jira-issue-to-pr/README.md +12 -4
  95. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  96. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  97. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  98. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  99. package/skills/linear-triage/SKILL.md +5 -5
  100. package/skills/news-digest/README.md +6 -0
  101. package/skills/news-digest/SKILL.md +19 -9
  102. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  103. package/skills/news-digest/scripts/main.py +133 -15
  104. package/skills/openhands-api/README.md +1 -1
  105. package/skills/openhands-api/SKILL.md +3 -3
  106. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  107. package/skills/openhands-automation/SKILL.md +66 -4
  108. package/skills/openhands-automation/references/custom-automation.md +21 -7
  109. package/skills/openhands-sdk/SKILL.md +1 -0
  110. package/skills/research-brief/SKILL.md +5 -5
  111. package/skills/slack-standup-digest/SKILL.md +5 -5
  112. package/skills/ticket-to-code-change/SKILL.md +6 -3
  113. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  114. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  115. package/skills/use-jev/.plugin/plugin.json +8 -0
  116. package/skills/use-jev/README.md +66 -0
  117. package/skills/use-jev/SKILL.md +66 -0
  118. package/tests/fixtures/automations/github-pr-reviewer.json +39 -24
  119. package/tests/test_agent_conversation_dispatch.py +212 -0
  120. package/tests/test_apps_catalog.py +26 -0
  121. package/tests/test_automation_setup.py +44 -0
  122. package/tests/test_check_pr_description.py +89 -6
  123. package/tests/test_github_automation_foundation.py +158 -0
  124. package/tests/test_github_repo_monitor.py +107 -0
  125. package/tests/test_github_reviewer_delivery.py +2247 -6
  126. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  127. package/tests/test_jira_issue_to_pr.py +312 -0
  128. package/tests/test_jira_setup_webhook.py +309 -0
  129. package/tests/test_openhands_api_python.py +37 -0
  130. package/tests/test_skill_plugin_loading.py +25 -15
  131. package/tests/test_sync_extensions.py +102 -0
  132. package/tests/test_vscode_app.py +33 -0
  133. package/tests/vscode-app.test.mjs +43 -0
@@ -20,7 +20,8 @@
20
20
  ],
21
21
  "githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN",
22
22
  "maintainers": "",
23
- "triggerReviewer": "all-hands-bot"
23
+ "triggerReviewer": "all-hands-bot",
24
+ "maxNewPerRun": 2
24
25
  },
25
26
  "upload": {
26
27
  "request": {
@@ -67,7 +68,7 @@
67
68
  "timeout": 300,
68
69
  "template": {
69
70
  "id": "github-pr-reviewer",
70
- "version": "1.5.0",
71
+ "version": "1.8.1",
71
72
  "config": {
72
73
  "repos": [
73
74
  "OpenHands/agent-server-gui"
@@ -76,7 +77,8 @@
76
77
  "review_tone": "thorough",
77
78
  "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
78
79
  "maintainers": "",
79
- "trigger_reviewer": "all-hands-bot"
80
+ "trigger_reviewer": "all-hands-bot",
81
+ "max_new_per_run": 2
80
82
  }
81
83
  }
82
84
  }
@@ -106,7 +108,7 @@
106
108
  "timeout": 300,
107
109
  "template": {
108
110
  "id": "github-pr-reviewer",
109
- "version": "1.5.0",
111
+ "version": "1.8.1",
110
112
  "config": {
111
113
  "repos": [
112
114
  "OpenHands/agent-server-gui"
@@ -115,7 +117,8 @@
115
117
  "review_tone": "thorough",
116
118
  "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
117
119
  "maintainers": "",
118
- "trigger_reviewer": "all-hands-bot"
120
+ "trigger_reviewer": "all-hands-bot",
121
+ "max_new_per_run": 2
119
122
  }
120
123
  }
121
124
  }
@@ -162,7 +165,8 @@
162
165
  ],
163
166
  "githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN",
164
167
  "maintainers": "",
165
- "triggerReviewer": "all-hands-bot"
168
+ "triggerReviewer": "all-hands-bot",
169
+ "maxNewPerRun": 2
166
170
  },
167
171
  "upload": {
168
172
  "request": {
@@ -209,7 +213,7 @@
209
213
  "timeout": 300,
210
214
  "template": {
211
215
  "id": "github-pr-reviewer",
212
- "version": "1.5.0",
216
+ "version": "1.8.1",
213
217
  "config": {
214
218
  "repos": [
215
219
  "OpenHands/agent-server-gui",
@@ -219,7 +223,8 @@
219
223
  "review_tone": "thorough",
220
224
  "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
221
225
  "maintainers": "",
222
- "trigger_reviewer": "all-hands-bot"
226
+ "trigger_reviewer": "all-hands-bot",
227
+ "max_new_per_run": 2
223
228
  }
224
229
  }
225
230
  }
@@ -249,7 +254,7 @@
249
254
  "timeout": 300,
250
255
  "template": {
251
256
  "id": "github-pr-reviewer",
252
- "version": "1.5.0",
257
+ "version": "1.8.1",
253
258
  "config": {
254
259
  "repos": [
255
260
  "OpenHands/agent-server-gui",
@@ -259,7 +264,8 @@
259
264
  "review_tone": "thorough",
260
265
  "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
261
266
  "maintainers": "",
262
- "trigger_reviewer": "all-hands-bot"
267
+ "trigger_reviewer": "all-hands-bot",
268
+ "max_new_per_run": 2
263
269
  }
264
270
  }
265
271
  }
@@ -290,7 +296,7 @@
290
296
  "preset_metadata": {
291
297
  "template": {
292
298
  "id": "github-pr-reviewer",
293
- "version": "1.5.0",
299
+ "version": "1.8.1",
294
300
  "config": {
295
301
  "repos": [
296
302
  "OpenHands/agent-server-gui",
@@ -300,7 +306,8 @@
300
306
  "review_tone": "thorough",
301
307
  "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
302
308
  "maintainers": "",
303
- "trigger_reviewer": "all-hands-bot"
309
+ "trigger_reviewer": "all-hands-bot",
310
+ "max_new_per_run": 2
304
311
  }
305
312
  }
306
313
  }
@@ -322,7 +329,8 @@
322
329
  ],
323
330
  "githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN",
324
331
  "maintainers": "",
325
- "triggerReviewer": "all-hands-bot"
332
+ "triggerReviewer": "all-hands-bot",
333
+ "maxNewPerRun": 2
326
334
  },
327
335
  "preflight": {
328
336
  "request": {
@@ -343,7 +351,7 @@
343
351
  "timeout": 300,
344
352
  "template": {
345
353
  "id": "github-pr-reviewer",
346
- "version": "1.5.0",
354
+ "version": "1.8.1",
347
355
  "config": {
348
356
  "repos": [
349
357
  "OpenHands/agent-server-gui"
@@ -352,7 +360,8 @@
352
360
  "review_tone": "concise",
353
361
  "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
354
362
  "maintainers": "",
355
- "trigger_reviewer": "all-hands-bot"
363
+ "trigger_reviewer": "all-hands-bot",
364
+ "max_new_per_run": 2
356
365
  }
357
366
  }
358
367
  }
@@ -390,7 +399,8 @@
390
399
  ],
391
400
  "githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN",
392
401
  "maintainers": "",
393
- "triggerReviewer": "all-hands-bot"
402
+ "triggerReviewer": "all-hands-bot",
403
+ "maxNewPerRun": 2
394
404
  },
395
405
  "upload": {
396
406
  "request": {
@@ -434,7 +444,7 @@
434
444
  "timeout": 300,
435
445
  "template": {
436
446
  "id": "github-pr-reviewer",
437
- "version": "1.5.0",
447
+ "version": "1.8.1",
438
448
  "config": {
439
449
  "repos": [
440
450
  "OpenHands/agent-server-gui"
@@ -443,7 +453,8 @@
443
453
  "review_tone": "concise",
444
454
  "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
445
455
  "maintainers": "",
446
- "trigger_reviewer": "all-hands-bot"
456
+ "trigger_reviewer": "all-hands-bot",
457
+ "max_new_per_run": 2
447
458
  }
448
459
  }
449
460
  }
@@ -484,7 +495,8 @@
484
495
  ],
485
496
  "githubTokenSecret": "GITHUB_PERSONAL_ACCESS_TOKEN",
486
497
  "maintainers": "",
487
- "triggerReviewer": "all-hands-bot"
498
+ "triggerReviewer": "all-hands-bot",
499
+ "maxNewPerRun": 2
488
500
  },
489
501
  "upload": {
490
502
  "request": {
@@ -528,7 +540,7 @@
528
540
  "timeout": 300,
529
541
  "template": {
530
542
  "id": "github-pr-reviewer",
531
- "version": "1.5.0",
543
+ "version": "1.8.1",
532
544
  "config": {
533
545
  "repos": [
534
546
  "OpenHands/agent-server-gui"
@@ -537,7 +549,8 @@
537
549
  "review_tone": "concise",
538
550
  "github_token_secret": "GITHUB_PERSONAL_ACCESS_TOKEN",
539
551
  "maintainers": "",
540
- "trigger_reviewer": "all-hands-bot"
552
+ "trigger_reviewer": "all-hands-bot",
553
+ "max_new_per_run": 2
541
554
  }
542
555
  },
543
556
  "repos": [
@@ -580,7 +593,8 @@
580
593
  "OpenHands/automation"
581
594
  ],
582
595
  "githubTokenSecret": "GITHUB_TOKEN_REVIEW",
583
- "maintainers": "neubig,malhotra5"
596
+ "maintainers": "neubig,malhotra5",
597
+ "maxNewPerRun": 2
584
598
  },
585
599
  "preflight": {
586
600
  "request": {
@@ -602,7 +616,7 @@
602
616
  "timeout": 300,
603
617
  "template": {
604
618
  "id": "github-pr-reviewer",
605
- "version": "1.5.0",
619
+ "version": "1.8.1",
606
620
  "config": {
607
621
  "repos": [
608
622
  "OpenHands/automation"
@@ -611,7 +625,8 @@
611
625
  "trigger_reviewer": "all-hands-bot",
612
626
  "review_tone": "concise",
613
627
  "maintainers": "neubig,malhotra5",
614
- "github_token_secret": "GITHUB_TOKEN_REVIEW"
628
+ "github_token_secret": "GITHUB_TOKEN_REVIEW",
629
+ "max_new_per_run": 2
615
630
  }
616
631
  }
617
632
  }
@@ -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 -->")