@openhands/extensions 0.17.0 → 0.18.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 (42) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/README.md +5 -2
  3. package/automations/README.md +3 -2
  4. package/automations/bundle-index.js +9 -0
  5. package/automations/catalog/github-agents-md-maintainer/manifest.json +96 -0
  6. package/automations/catalog/github-issue-to-pr/manifest.json +107 -0
  7. package/automations/catalog/github-pr-reviewer/manifest.json +1 -0
  8. package/automations/catalog/github-repo-monitor/manifest.json +1 -0
  9. package/automations/catalog/news-digest/manifest.json +76 -0
  10. package/automations/catalog-index.js +13 -7
  11. package/automations/catalog.schema.json +10 -2
  12. package/automations/index.d.ts +18 -1
  13. package/automations/interface.json +3 -1
  14. package/marketplaces/openhands-extensions.json +38 -0
  15. package/package.json +1 -1
  16. package/pyproject.toml +1 -1
  17. package/python/openhands_extensions/_version.py +1 -1
  18. package/skills/github-agents-md-maintainer/.plugin/plugin.json +19 -0
  19. package/skills/github-agents-md-maintainer/README.md +59 -0
  20. package/skills/github-agents-md-maintainer/SKILL.md +271 -0
  21. package/skills/github-agents-md-maintainer/commands/agents-md-setup.md +8 -0
  22. package/skills/github-agents-md-maintainer/references/state-schema.md +113 -0
  23. package/skills/github-agents-md-maintainer/scripts/main.py +1187 -0
  24. package/skills/github-issue-to-pr/.plugin/plugin.json +19 -0
  25. package/skills/github-issue-to-pr/README.md +84 -0
  26. package/skills/github-issue-to-pr/SKILL.md +373 -0
  27. package/skills/github-issue-to-pr/commands/issue-to-pr-setup.md +8 -0
  28. package/skills/github-issue-to-pr/references/state-schema.md +139 -0
  29. package/skills/github-issue-to-pr/scripts/main.py +1305 -0
  30. package/skills/index.js +27 -0
  31. package/skills/news-digest/.plugin/plugin.json +19 -0
  32. package/skills/news-digest/README.md +71 -0
  33. package/skills/news-digest/SKILL.md +284 -0
  34. package/skills/news-digest/commands/news-digest-setup.md +8 -0
  35. package/skills/news-digest/references/state-schema.md +178 -0
  36. package/skills/news-digest/scripts/main.py +1250 -0
  37. package/tests/fixtures/automations/github-repo-monitor.json +77 -0
  38. package/tests/test_automation_setup.py +34 -0
  39. package/tests/test_catalogs.py +3 -1
  40. package/tests/test_github_agents_md_maintainer.py +352 -0
  41. package/tests/test_github_issue_to_pr.py +539 -0
  42. package/tests/test_news_digest.py +646 -0
@@ -37,6 +37,17 @@
37
37
  "type": "cron",
38
38
  "schedule": "*/15 * * * *",
39
39
  "timezone": "UTC"
40
+ },
41
+ "template": {
42
+ "id": "github-repo-monitor",
43
+ "version": "1.0.0",
44
+ "config": {
45
+ "repository": "OpenHands/agent-server-gui",
46
+ "triggerPhrase": "@openhands",
47
+ "ref": "main",
48
+ "schedule": "*/15 * * * *",
49
+ "timezone": "UTC"
50
+ }
40
51
  }
41
52
  }
42
53
  }
@@ -67,6 +78,17 @@
67
78
  "type": "cron",
68
79
  "schedule": "*/15 * * * *",
69
80
  "timezone": "UTC"
81
+ },
82
+ "template": {
83
+ "id": "github-repo-monitor",
84
+ "version": "1.0.0",
85
+ "config": {
86
+ "repository": "OpenHands/agent-server-gui",
87
+ "triggerPhrase": "@openhands",
88
+ "ref": "main",
89
+ "schedule": "*/15 * * * *",
90
+ "timezone": "UTC"
91
+ }
70
92
  }
71
93
  }
72
94
  },
@@ -79,6 +101,28 @@
79
101
  "model": null,
80
102
  "name": "GitHub repository monitor - OpenHands/agent-server-gui",
81
103
  "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
104
+ "preset_metadata": {
105
+ "preset_type": "prompt",
106
+ "prompt": "Poll OpenHands/agent-server-gui for any new issue or pull request comments since the last run. For every comment mentioning '@openhands', read the surrounding issue or pull request context and post a helpful reply as a comment on the same thread.",
107
+ "repos": [
108
+ {
109
+ "url": "OpenHands/agent-server-gui",
110
+ "ref": "main",
111
+ "provider": "github"
112
+ }
113
+ ],
114
+ "template": {
115
+ "id": "github-repo-monitor",
116
+ "version": "1.0.0",
117
+ "config": {
118
+ "repository": "OpenHands/agent-server-gui",
119
+ "triggerPhrase": "@openhands",
120
+ "ref": "main",
121
+ "schedule": "*/15 * * * *",
122
+ "timezone": "UTC"
123
+ }
124
+ }
125
+ },
82
126
  "trigger": {
83
127
  "type": "cron",
84
128
  "schedule": "*/15 * * * *",
@@ -128,6 +172,17 @@
128
172
  "type": "cron",
129
173
  "schedule": "*/1 * * * *",
130
174
  "timezone": "UTC"
175
+ },
176
+ "template": {
177
+ "id": "github-repo-monitor",
178
+ "version": "1.0.0",
179
+ "config": {
180
+ "repository": "OpenHands/agent-server-gui",
181
+ "triggerPhrase": "@openhands",
182
+ "ref": "main",
183
+ "schedule": "*/1 * * * *",
184
+ "timezone": "UTC"
185
+ }
131
186
  }
132
187
  }
133
188
  }
@@ -178,6 +233,17 @@
178
233
  "type": "cron",
179
234
  "schedule": "0 0 31 2 *",
180
235
  "timezone": "UTC"
236
+ },
237
+ "template": {
238
+ "id": "github-repo-monitor",
239
+ "version": "1.0.0",
240
+ "config": {
241
+ "repository": "OpenHands/agent-server-gui",
242
+ "triggerPhrase": "@openhands",
243
+ "ref": "main",
244
+ "schedule": "0 0 31 2 *",
245
+ "timezone": "UTC"
246
+ }
181
247
  }
182
248
  }
183
249
  },
@@ -229,6 +295,17 @@
229
295
  "type": "cron",
230
296
  "schedule": "*/15 * * * *",
231
297
  "timezone": "UTC"
298
+ },
299
+ "template": {
300
+ "id": "github-repo-monitor",
301
+ "version": "1.0.0",
302
+ "config": {
303
+ "repository": "OpenHands/agent-server-gui",
304
+ "triggerPhrase": "@openhands",
305
+ "ref": "main",
306
+ "schedule": "*/15 * * * *",
307
+ "timezone": "UTC"
308
+ }
232
309
  }
233
310
  }
234
311
  },
@@ -278,6 +278,16 @@ def _render_payload(entry: dict, form_values: dict, tarball_path: str = "") -> d
278
278
 
279
279
  body["trigger"] = _derive_trigger(entry, form_values)
280
280
 
281
+ # A versioned entry sends its provenance: the service stores it opaquely,
282
+ # keyed by id for idempotent creation. The form values are non-secret by
283
+ # design (credentials come only from connected integrations).
284
+ if "version" in entry:
285
+ body["template"] = {
286
+ "id": entry["id"],
287
+ "version": entry["version"],
288
+ "config": dict(form_values),
289
+ }
290
+
281
291
  return body
282
292
 
283
293
 
@@ -497,6 +507,10 @@ def test_schema_rejects_content_a_setup_block_must_never_carry() -> None:
497
507
  with_repeated_identity["setup"]["description"] = "a second description"
498
508
  rejected.append(("description", with_repeated_identity))
499
509
 
510
+ with_bad_version = deepcopy(entry)
511
+ with_bad_version["version"] = "1.0"
512
+ rejected.append(("'1.0' does not match", with_bad_version))
513
+
500
514
  for expected_fragment, invalid in rejected:
501
515
  errors = list(VALIDATOR.iter_errors(invalid))
502
516
  assert any(expected_fragment in error.message for error in errors), (
@@ -520,6 +534,26 @@ def test_form_placeholders_reference_declared_fields(entry_path: Path) -> None:
520
534
  assert referenced - fields == set()
521
535
 
522
536
 
537
+ @pytest.mark.parametrize("entry_path", list(_setup_paths()))
538
+ def test_the_declared_features_match_the_archetype(entry_path: Path) -> None:
539
+ """A bundle needs a deployment that runs a client-supplied tarball; a prompt
540
+ needs the preset endpoint. Declaring the other one's features is a check the
541
+ host runs against the wrong capability, so the entry is either offered where
542
+ it cannot run or withheld where it can."""
543
+ entry = _load(entry_path)
544
+ features = set(entry["requires"].get("features", []))
545
+
546
+ if _is_bundle(entry):
547
+ assert "customTarball" in features, f"{entry['id']}: a bundle must declare customTarball"
548
+ assert "presetPrompt" not in features, (
549
+ f"{entry['id']}: a bundle creates through {BUNDLE_CREATE_PATH}, not the preset endpoint"
550
+ )
551
+ else:
552
+ assert "customTarball" not in features, (
553
+ f"{entry['id']}: only a bundle uploads a tarball"
554
+ )
555
+
556
+
523
557
  @pytest.mark.parametrize("entry_path", list(_setup_paths()))
524
558
  def test_select_fields_offer_options(entry_path: Path) -> None:
525
559
  """A select without options is an empty dropdown the user cannot get past.
@@ -34,8 +34,10 @@ def test_catalog_ids_are_unique_and_automations_reference_things_that_exist():
34
34
 
35
35
  known_integration_ids = set(integration_ids)
36
36
  for automation in automations:
37
+ # May be empty: an automation that needs nothing connected says so with
38
+ # an empty object rather than by leaving the key out. What it may not do
39
+ # is name an integration that does not exist.
37
40
  required = automation["requires"]["integrations"]
38
- assert required
39
41
  assert set(required) - known_integration_ids == set()
40
42
 
41
43
  # The launch command is looked up from this skill rather than stored on
@@ -0,0 +1,352 @@
1
+ """Unit tests for the github-agents-md-maintainer automation script.
2
+
3
+ The focus is what the script decides on its own: which week it is working, when
4
+ it declines to work at all, where it clones to, and what it hands the agent.
5
+ """
6
+
7
+ import importlib.util
8
+ import json
9
+ import subprocess
10
+ import time
11
+ import urllib.error
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ SCRIPT_PATH = (
17
+ Path(__file__).parent.parent
18
+ / "skills"
19
+ / "github-agents-md-maintainer"
20
+ / "scripts"
21
+ / "main.py"
22
+ )
23
+
24
+
25
+ @pytest.fixture
26
+ def main(monkeypatch, tmp_path):
27
+ monkeypatch.setenv("WORKSPACE_BASE", str(tmp_path / "workspace"))
28
+ monkeypatch.delenv("AUTOMATION_KV_TOKEN", raising=False)
29
+ monkeypatch.delenv("AUTOMATION_API_URL", raising=False)
30
+ spec = importlib.util.spec_from_file_location("agents_md_main", SCRIPT_PATH)
31
+ module = importlib.util.module_from_spec(spec)
32
+ spec.loader.exec_module(module)
33
+ return module
34
+
35
+
36
+ def _http_error(code: int) -> urllib.error.HTTPError:
37
+ return urllib.error.HTTPError("https://api.github.com", code, "err", {}, None)
38
+
39
+
40
+ # ── One unit of work is one ISO week ──────────────────────────────────────────
41
+
42
+
43
+ def test_the_period_is_the_iso_week(main):
44
+ """A cron that fires more often, a retried run, or a restarted service all
45
+ resolve to the same key, so the work happens once."""
46
+ assert main._current_period() == time.strftime("%G-W%V", time.gmtime())
47
+ assert main._task_key("2026-W34") == "agents-md:2026-W34"
48
+
49
+
50
+ def test_the_branch_carries_the_period(main, monkeypatch):
51
+ monkeypatch.setattr(main, "_github_request", lambda *a, **k: (_ for _ in ()).throw(_http_error(404)))
52
+
53
+ assert main._branch_name("tok", "acme/widget", "2026-W34") == "openhands/agents-md-2026-W34"
54
+
55
+
56
+ def test_a_taken_branch_name_is_skipped(main, monkeypatch):
57
+ taken = {"openhands/agents-md-2026-W34"}
58
+
59
+ def fake(token, method, path, params=None, body=None):
60
+ branch = path.split("/git/ref/heads/", 1)[1]
61
+ if branch in taken:
62
+ return {"ref": branch}, {}
63
+ raise _http_error(404)
64
+
65
+ monkeypatch.setattr(main, "_github_request", fake)
66
+
67
+ assert main._branch_name("tok", "acme/widget", "2026-W34") == "openhands/agents-md-2026-W34-2"
68
+
69
+
70
+ # ── Declining to work ─────────────────────────────────────────────────────────
71
+
72
+
73
+ def test_only_this_automations_pull_requests_count_as_in_flight(main, monkeypatch):
74
+ """Someone else's open pull request must not silence the automation, and its
75
+ own must."""
76
+ monkeypatch.setattr(
77
+ main,
78
+ "_github_paginate",
79
+ lambda token, path, params=None: [
80
+ {"number": 1, "head": {"ref": "feature/something"}},
81
+ {"number": 2, "head": {"ref": "openhands/agents-md-2026-W33"}},
82
+ {"number": 3, "head": {"ref": "openhands/issue-42"}},
83
+ ],
84
+ )
85
+
86
+ in_flight = main._open_pull_requests_from_this_automation("tok", "acme/widget")
87
+
88
+ assert [pr["number"] for pr in in_flight] == [2]
89
+
90
+
91
+ def test_a_listing_failure_does_not_claim_a_pull_request_is_open(main, monkeypatch):
92
+ """Failing closed here would mean an API blip silently skips the week."""
93
+
94
+ def boom(*args, **kwargs):
95
+ raise RuntimeError("GitHub unreachable")
96
+
97
+ monkeypatch.setattr(main, "_github_paginate", boom)
98
+
99
+ assert main._open_pull_requests_from_this_automation("tok", "acme/widget") == []
100
+
101
+
102
+ # ── Create or update ──────────────────────────────────────────────────────────
103
+
104
+
105
+ def test_a_missing_agents_file_is_detected(main, monkeypatch):
106
+ monkeypatch.setattr(main, "_github_request", lambda *a, **k: (_ for _ in ()).throw(_http_error(404)))
107
+
108
+ assert main._agents_file_state("tok", "acme/widget", "main") == "missing"
109
+ assert main._pull_request_title("missing") == "docs: add AGENTS.md"
110
+
111
+
112
+ def test_an_unreadable_answer_is_treated_as_present(main, monkeypatch):
113
+ """Proposing to "add" a file that already exists reads worse than the
114
+ reverse, so an API failure resolves to update."""
115
+ monkeypatch.setattr(main, "_github_request", lambda *a, **k: (_ for _ in ()).throw(_http_error(500)))
116
+
117
+ assert main._agents_file_state("tok", "acme/widget", "main") == "present"
118
+ assert main._pull_request_title("present") == "docs: update AGENTS.md"
119
+
120
+
121
+ # ── The prompt ────────────────────────────────────────────────────────────────
122
+
123
+
124
+ def _prompt(main, state="present"):
125
+ return main._build_maintenance_prompt(
126
+ "acme/widget", state, "openhands/agents-md-2026-W34", "main", "abc123", "2026-W34"
127
+ )
128
+
129
+
130
+ def test_the_prompt_asks_for_an_edit_not_a_rewrite(main):
131
+ prompt = _prompt(main)
132
+
133
+ assert "update it so it matches" in prompt
134
+ assert "This is an edit, not a rewrite" in prompt
135
+ assert "someone else's writing" in prompt
136
+
137
+
138
+ def test_the_prompt_states_what_belongs_in_the_file(main):
139
+ """The rules are the ones the agent-memory skill sets out."""
140
+ prompt = _prompt(main)
141
+
142
+ assert "task-specific" in prompt
143
+ assert "have not verified" in prompt
144
+ assert "No secrets" in prompt
145
+
146
+
147
+ def test_the_prompt_allows_doing_nothing(main):
148
+ """Most weeks the file is fine, and an edit made to look busy is worse."""
149
+ prompt = _prompt(main)
150
+
151
+ assert "change nothing, open nothing" in prompt
152
+
153
+
154
+ def test_the_prompt_tells_the_agent_to_open_the_pull_request(main):
155
+ prompt = _prompt(main)
156
+
157
+ assert "x-access-token:$GITHUB_PERSONAL_ACCESS_TOKEN" in prompt
158
+ assert "gh pr create --repo acme/widget --base main --head openhands/agents-md-2026-W34" in prompt
159
+ assert "--draft" in prompt
160
+ assert "GITHUB_PR_OPENED" in prompt
161
+
162
+
163
+ def test_a_ready_for_review_configuration_drops_the_draft_flag(main, monkeypatch):
164
+ monkeypatch.setattr(main, "DRAFT_PULL_REQUEST", False)
165
+ prompt = _prompt(main)
166
+
167
+ assert "--draft" not in prompt
168
+ assert "ready for review" in prompt
169
+
170
+
171
+ def test_the_prompt_keeps_the_untrusted_input_boundary(main):
172
+ prompt = _prompt(main)
173
+
174
+ assert "untrusted input" in prompt
175
+ assert "repositories other than acme/widget" in prompt
176
+
177
+
178
+ # ── Configuration ─────────────────────────────────────────────────────────────
179
+
180
+
181
+ def test_config_json_overrides_the_constants(main, tmp_path):
182
+ (tmp_path / "config.json").write_text(
183
+ json.dumps(
184
+ {
185
+ "repos": ["acme/one"],
186
+ "branch_prefix": "bot/agents",
187
+ "pull_request_mode": "ready",
188
+ "max_new_per_run": 2,
189
+ "agent_secret_names": ["GITHUB_PERSONAL_ACCESS_TOKEN"],
190
+ }
191
+ )
192
+ )
193
+
194
+ config = main.load_config(tmp_path)
195
+
196
+ assert config["branch_prefix"] == "bot/agents"
197
+ assert config["pull_request_mode"] == "ready"
198
+
199
+
200
+ def test_the_github_token_is_forwarded_by_default(main, monkeypatch):
201
+ monkeypatch.setattr(
202
+ main,
203
+ "_list_secret_names",
204
+ lambda a, k: [{"name": "GITHUB_PERSONAL_ACCESS_TOKEN"}, {"name": "AWS_SECRET_ACCESS_KEY"}],
205
+ )
206
+
207
+ assert main.AGENT_SECRET_NAMES == ["GITHUB_PERSONAL_ACCESS_TOKEN"]
208
+ assert list(main._build_secrets_payload("http://agent", "key")) == [
209
+ "GITHUB_PERSONAL_ACCESS_TOKEN"
210
+ ]
211
+
212
+
213
+ # ── Files on disk ─────────────────────────────────────────────────────────────
214
+
215
+
216
+ def test_the_clone_lives_under_its_own_root(main):
217
+ path = main._checkout_path("acme/widget", "2026-W34")
218
+
219
+ assert path.parent.parent == main._checkouts_root()
220
+ assert str(path).endswith("agents-md/acme__widget/2026-W34")
221
+
222
+
223
+ def test_a_path_outside_the_checkout_root_is_never_removed(main, monkeypatch, tmp_path):
224
+ outside = tmp_path / "precious"
225
+ outside.mkdir()
226
+ rec = {"workspace_dir": str(outside), "conversation_id": "conv-1"}
227
+ monkeypatch.setattr(main, "conversation_status", lambda *a: "finished")
228
+
229
+ assert main._release_checkout(rec, "http://agent", "key") is True
230
+ assert outside.exists()
231
+
232
+
233
+ def test_a_running_conversation_keeps_its_clone(main, monkeypatch):
234
+ checkout = main._checkout_path("acme/widget", "2026-W34")
235
+ checkout.mkdir(parents=True)
236
+ rec = {"workspace_dir": str(checkout), "conversation_id": "conv-1"}
237
+ monkeypatch.setattr(main, "conversation_status", lambda *a: "running")
238
+
239
+ assert main._release_checkout(rec, "http://agent", "key") is False
240
+ assert checkout.exists()
241
+
242
+
243
+ def _init_repo(path: Path) -> str:
244
+ path.mkdir(parents=True, exist_ok=True)
245
+ run = lambda *a: subprocess.run(["git", *a], cwd=path, check=True, capture_output=True)
246
+ run("init", "-q", "-b", "main")
247
+ run("config", "user.name", "Test")
248
+ run("config", "user.email", "test@example.com")
249
+ (path / "README.md").write_text("base\n")
250
+ run("add", "-A")
251
+ run("commit", "-q", "-m", "base")
252
+ return subprocess.run(
253
+ ["git", "rev-parse", "HEAD"], cwd=path, check=True, capture_output=True, text=True
254
+ ).stdout.strip()
255
+
256
+
257
+ def test_an_edited_agents_file_is_committed_and_counted(main, tmp_path):
258
+ checkout = tmp_path / "clone"
259
+ base = _init_repo(checkout)
260
+ (checkout / "AGENTS.md").write_text("# AGENTS\n\nRun `pytest`.\n")
261
+
262
+ assert main._commit_agent_work(checkout, base) == 1
263
+
264
+
265
+ def test_an_untouched_repository_produces_no_commits(main, tmp_path):
266
+ """No commits is the expected weekly outcome, not a failure."""
267
+ checkout = tmp_path / "clone"
268
+ base = _init_repo(checkout)
269
+
270
+ assert main._commit_agent_work(checkout, base) == 0
271
+
272
+
273
+ # ── Pull request body ─────────────────────────────────────────────────────────
274
+
275
+
276
+ def test_the_pull_request_body_dates_the_run_and_discloses_the_agent(main):
277
+ body = main._pull_request_body("acme/widget", "Refreshed the test command.", "http://oh/c/1", "2026-W34")
278
+
279
+ assert "Refreshed the test command." in body
280
+ assert "2026-W34" in body
281
+ assert "_This pull request was opened by an AI agent (OpenHands)._" in body
282
+
283
+
284
+ def test_a_capped_run_still_finalizes_but_starts_nothing(main, monkeypatch, tmp_path):
285
+ """The per-run cap limits new conversations. A repository beyond it still
286
+ has an in-flight task to finish - skipping the repository outright would
287
+ leave its pull request unopened and its clone on disk."""
288
+ started = []
289
+ finalized = []
290
+
291
+ monkeypatch.setattr(
292
+ main, "_get_repo", lambda token, repo: {"default_branch": "main", "permissions": {"push": True}}
293
+ )
294
+ monkeypatch.setattr(main, "_open_pull_requests_from_this_automation", lambda t, r: [])
295
+ monkeypatch.setattr(main, "_agents_file_state", lambda t, r, b: "present")
296
+ monkeypatch.setattr(main, "_start_task", lambda *a, **k: started.append(1))
297
+ monkeypatch.setattr(main, "_finalize_task", lambda *a, **k: finalized.append(1))
298
+
299
+ period = main._current_period()
300
+ main.save_state(
301
+ "acme/widget",
302
+ {
303
+ "version": 1,
304
+ "repo": "acme/widget",
305
+ "tasks": {
306
+ "agents-md:2026-W01": {
307
+ "period": "2026-W01",
308
+ "status": "active",
309
+ "conversation_id": "c1",
310
+ "last_activity": 0,
311
+ }
312
+ },
313
+ },
314
+ )
315
+
316
+ main._process_repo("acme/widget", "tok", "http://agent", "key", "http://oh", may_start=False)
317
+
318
+ assert finalized == [1], "a capped run must still finish work already in flight"
319
+ assert started == [], "a capped run must not start new work"
320
+ assert main._task_key(period) not in main.load_state("acme/widget")["tasks"]
321
+
322
+
323
+ def test_the_entry_declares_the_icon_its_card_should_show():
324
+ """A card whose identity is the work, not the service it talks to, names its
325
+ own glyph; the host derives one from the integrations otherwise."""
326
+ entry = json.loads(
327
+ (
328
+ Path(__file__).parent.parent
329
+ / "automations"
330
+ / "catalog"
331
+ / "github-agents-md-maintainer"
332
+ / "manifest.json"
333
+ ).read_text()
334
+ )
335
+ schema = json.loads(
336
+ (Path(__file__).parent.parent / "automations" / "catalog.schema.json").read_text()
337
+ )
338
+
339
+ assert entry["icon"] == "bot"
340
+ assert entry["icon"] in schema["properties"]["icon"]["enum"]
341
+
342
+
343
+ def test_the_icon_vocabulary_matches_the_interface_manifest():
344
+ """Two closed sets for the same artwork would drift; the card icons are the
345
+ navigation's icons."""
346
+ root = Path(__file__).parent.parent
347
+ catalog = json.loads((root / "automations" / "catalog.schema.json").read_text())
348
+ interface = json.loads((root / "automations" / "interface.schema.json").read_text())
349
+
350
+ nav_icons = json.dumps(interface)
351
+ for slug in catalog["properties"]["icon"]["enum"]:
352
+ assert f'"{slug}"' in nav_icons, f"{slug} is not an icon the interface manifest knows"