agenticros 0.1.18 → 0.2.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 (164) hide show
  1. package/dist/__tests__/robot-config.test.d.ts +2 -0
  2. package/dist/__tests__/robot-config.test.d.ts.map +1 -0
  3. package/dist/__tests__/robot-config.test.js +374 -0
  4. package/dist/__tests__/robot-config.test.js.map +1 -0
  5. package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
  6. package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
  7. package/dist/__tests__/transport-shorthand.test.js +88 -0
  8. package/dist/__tests__/transport-shorthand.test.js.map +1 -0
  9. package/dist/commands/robots.d.ts +57 -0
  10. package/dist/commands/robots.d.ts.map +1 -0
  11. package/dist/commands/robots.js +678 -0
  12. package/dist/commands/robots.js.map +1 -0
  13. package/dist/commands/skills.d.ts.map +1 -1
  14. package/dist/commands/skills.js +125 -2
  15. package/dist/commands/skills.js.map +1 -1
  16. package/dist/index.js +29 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/menu.js +22 -4
  19. package/dist/menu.js.map +1 -1
  20. package/dist/util/marketplace.d.ts +57 -0
  21. package/dist/util/marketplace.d.ts.map +1 -0
  22. package/dist/util/marketplace.js +144 -0
  23. package/dist/util/marketplace.js.map +1 -0
  24. package/dist/util/mcp-discovery.d.ts +57 -0
  25. package/dist/util/mcp-discovery.d.ts.map +1 -0
  26. package/dist/util/mcp-discovery.js +153 -0
  27. package/dist/util/mcp-discovery.js.map +1 -0
  28. package/dist/util/robot-config.d.ts +167 -0
  29. package/dist/util/robot-config.d.ts.map +1 -0
  30. package/dist/util/robot-config.js +375 -0
  31. package/dist/util/robot-config.js.map +1 -0
  32. package/dist/util/skills.d.ts +6 -6
  33. package/dist/util/skills.d.ts.map +1 -1
  34. package/dist/util/skills.js +14 -9
  35. package/dist/util/skills.js.map +1 -1
  36. package/dist/util/transport-shorthand.d.ts +66 -0
  37. package/dist/util/transport-shorthand.d.ts.map +1 -0
  38. package/dist/util/transport-shorthand.js +102 -0
  39. package/dist/util/transport-shorthand.js.map +1 -0
  40. package/package.json +12 -10
  41. package/runtime/BUNDLE.json +1 -1
  42. package/runtime/README.md +62 -1
  43. package/runtime/package.json +6 -1
  44. package/runtime/packages/agenticros/openclaw.plugin.json +7 -1
  45. package/runtime/packages/agenticros/package.json +2 -1
  46. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
  47. package/runtime/packages/agenticros/src/config-page.ts +1 -1
  48. package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
  49. package/runtime/packages/agenticros/src/service.ts +120 -2
  50. package/runtime/packages/agenticros/src/skill-loader.ts +109 -30
  51. package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
  52. package/runtime/packages/agenticros/src/tools/index.ts +49 -9
  53. package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
  54. package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
  55. package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
  56. package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
  57. package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
  58. package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
  59. package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
  60. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
  61. package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
  62. package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
  63. package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
  64. package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
  65. package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
  66. package/runtime/packages/agenticros-claude-code/README.md +52 -9
  67. package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.d.ts +2 -0
  68. package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.d.ts.map +1 -0
  69. package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.js +773 -0
  70. package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.js.map +1 -0
  71. package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.d.ts +2 -0
  72. package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.d.ts.map +1 -0
  73. package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.js +97 -0
  74. package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.js.map +1 -0
  75. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts +2 -2
  76. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts.map +1 -1
  77. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js +5 -5
  78. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js.map +1 -1
  79. package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.d.ts +109 -0
  80. package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.d.ts.map +1 -0
  81. package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.js +392 -0
  82. package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.js.map +1 -0
  83. package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.d.ts +8 -3
  84. package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.d.ts.map +1 -1
  85. package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.js +30 -12
  86. package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.js.map +1 -1
  87. package/runtime/packages/agenticros-claude-code/dist/index.js +5 -5
  88. package/runtime/packages/agenticros-claude-code/dist/index.js.map +1 -1
  89. package/runtime/packages/agenticros-claude-code/dist/mission-registry.d.ts +16 -0
  90. package/runtime/packages/agenticros-claude-code/dist/mission-registry.d.ts.map +1 -0
  91. package/runtime/packages/agenticros-claude-code/dist/mission-registry.js +19 -0
  92. package/runtime/packages/agenticros-claude-code/dist/mission-registry.js.map +1 -0
  93. package/runtime/packages/agenticros-claude-code/dist/tools.d.ts +9 -0
  94. package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
  95. package/runtime/packages/agenticros-claude-code/dist/tools.js +700 -54
  96. package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
  97. package/runtime/packages/agenticros-claude-code/dist/transport.d.ts +28 -8
  98. package/runtime/packages/agenticros-claude-code/dist/transport.d.ts.map +1 -1
  99. package/runtime/packages/agenticros-claude-code/dist/transport.js +37 -34
  100. package/runtime/packages/agenticros-claude-code/dist/transport.js.map +1 -1
  101. package/runtime/packages/agenticros-claude-code/package.json +2 -1
  102. package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +965 -0
  103. package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
  104. package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
  105. package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
  106. package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
  107. package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
  108. package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
  109. package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
  110. package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
  111. package/runtime/packages/agenticros-gemini/package.json +1 -0
  112. package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
  113. package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
  114. package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
  115. package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
  116. package/runtime/packages/core/LICENSE +192 -0
  117. package/runtime/packages/core/README.md +88 -0
  118. package/runtime/packages/core/package.json +36 -3
  119. package/runtime/packages/core/src/__tests__/capabilities.test.ts +266 -0
  120. package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
  121. package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
  122. package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
  123. package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
  124. package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
  125. package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
  126. package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
  127. package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
  128. package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
  129. package/runtime/packages/core/src/capabilities.ts +314 -0
  130. package/runtime/packages/core/src/config.ts +221 -39
  131. package/runtime/packages/core/src/discovery.ts +193 -0
  132. package/runtime/packages/core/src/find-robots-for.ts +164 -0
  133. package/runtime/packages/core/src/index.ts +67 -1
  134. package/runtime/packages/core/src/mission-registry.ts +142 -0
  135. package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
  136. package/runtime/packages/core/src/mission.ts +574 -0
  137. package/runtime/packages/core/src/planner/index.ts +564 -0
  138. package/runtime/packages/core/src/robots.ts +282 -0
  139. package/runtime/packages/core/src/topic-utils.ts +47 -9
  140. package/runtime/packages/core/src/transport-pool.ts +171 -0
  141. package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
  142. package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
  143. package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
  144. package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
  145. package/runtime/scripts/configure_agenticros.sh +0 -0
  146. package/runtime/scripts/configure_for_sim.sh +0 -0
  147. package/runtime/scripts/fix-openclaw-control-ui-path.sh +0 -0
  148. package/runtime/scripts/install_cli.sh +0 -0
  149. package/runtime/scripts/install_rosbridge_from_source.sh +0 -0
  150. package/runtime/scripts/onboard_robot.sh +0 -0
  151. package/runtime/scripts/pack-runtime.mjs +0 -0
  152. package/runtime/scripts/refresh-skill-deps.mjs +381 -0
  153. package/runtime/scripts/run_demo_native.sh +0 -0
  154. package/runtime/scripts/run_nemoclaw_host_stack.sh +0 -0
  155. package/runtime/scripts/run_robot_rosbridge.sh +0 -0
  156. package/runtime/scripts/setup_gateway_plugin.sh +32 -5
  157. package/runtime/scripts/setup_robot.sh +0 -0
  158. package/runtime/scripts/setup_workspace.sh +0 -0
  159. package/runtime/scripts/sim/run_sim.sh +0 -0
  160. package/runtime/scripts/smoke_test_nemoclaw.sh +0 -0
  161. package/runtime/scripts/start_demo.sh +0 -0
  162. package/runtime/scripts/sync-skill-tools.mjs +69 -12
  163. package/runtime/scripts/use-openclaw-2026.2.26.sh +0 -0
  164. package/runtime/scripts/use-openclaw-2026.3.11.sh +0 -0
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Unit tests for the Phase 1 capability registry.
3
+ *
4
+ * The four broken-link failure cascade we hit on 2026-06-10 is exactly the
5
+ * regression class these tests are here to prevent:
6
+ * 1. Stale @agenticros/core caches in skill repos hiding new exports.
7
+ * 2. `sync-skill-tools.mjs` overwriting `contracts.tools` with a stale list.
8
+ * 3. OpenClaw `tools.alsoAllow` filtering out unallowlisted plugin tools.
9
+ * 4. The plugin-deploy `dist/` falling behind the workspace.
10
+ *
11
+ * The most upstream of those is "the reader produces what we promised";
12
+ * everything else depends on that contract holding. So this file pins down:
13
+ * - BUILTIN_CAPABILITIES has the 6 intrinsic verbs we ship.
14
+ * - readSkillCapabilities() picks up the `agenticros` block from package.json.
15
+ * - It uses `agenticros.id` (not the package name) as the skill id.
16
+ * - It tags every capability with a source so dispatchers can filter.
17
+ * - It deduplicates within a skill so a malformed manifest can't blow up.
18
+ * - listAllCapabilities() merges intrinsic + skill in that order.
19
+ * - It tolerates non-skill packages (no `agenticros` key) silently.
20
+ */
21
+ import { test } from "node:test";
22
+ import assert from "node:assert/strict";
23
+ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
24
+ import { tmpdir } from "node:os";
25
+ import path from "node:path";
26
+
27
+ import { parseConfig } from "../config.js";
28
+ import {
29
+ BUILTIN_CAPABILITIES,
30
+ readSkillCapabilities,
31
+ listAllCapabilities,
32
+ type Capability,
33
+ } from "../capabilities.js";
34
+
35
+ async function writeJson(filePath: string, data: unknown): Promise<void> {
36
+ await writeFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
37
+ }
38
+
39
+ async function makeSkillDir(
40
+ root: string,
41
+ name: string,
42
+ pkg: Record<string, unknown>,
43
+ ): Promise<string> {
44
+ const dir = path.join(root, name);
45
+ await mkdir(dir, { recursive: true });
46
+ await writeJson(path.join(dir, "package.json"), pkg);
47
+ return dir;
48
+ }
49
+
50
+ test("BUILTIN_CAPABILITIES exposes the six intrinsic robot verbs", () => {
51
+ const ids = BUILTIN_CAPABILITIES.map((c) => c.id).sort();
52
+ assert.deepEqual(ids, [
53
+ "drive_base",
54
+ "list_topics",
55
+ "measure_depth",
56
+ "publish_topic",
57
+ "subscribe_once",
58
+ "take_snapshot",
59
+ ]);
60
+ for (const c of BUILTIN_CAPABILITIES) {
61
+ assert.equal(c.source?.kind, "builtin", `${c.id} should be tagged builtin`);
62
+ assert.ok(c.verb.length > 0, `${c.id} should have a verb`);
63
+ assert.ok(c.description.length > 0, `${c.id} should have a description`);
64
+ }
65
+ });
66
+
67
+ test("readSkillCapabilities: reads capabilities from the `agenticros` block", async () => {
68
+ const root = await mkdtemp(path.join(tmpdir(), "agenticros-caps-"));
69
+ try {
70
+ const skillDir = await makeSkillDir(root, "skill-alpha", {
71
+ name: "agenticros-skill-alpha",
72
+ version: "0.0.1",
73
+ main: "dist/index.js",
74
+ agenticros: {
75
+ id: "alpha",
76
+ displayName: "Alpha",
77
+ capabilities: [
78
+ {
79
+ id: "wave_hello",
80
+ verb: "greet",
81
+ description: "Wave the arm.",
82
+ inputs: { duration_s: { type: "number", optional: true } },
83
+ interruptible: true,
84
+ },
85
+ {
86
+ id: "patrol_room",
87
+ verb: "patrol",
88
+ description: "Patrol until stopped.",
89
+ blocks_base: true,
90
+ },
91
+ ],
92
+ },
93
+ });
94
+ const config = parseConfig({ skillPaths: [skillDir] });
95
+ const caps = readSkillCapabilities(config);
96
+
97
+ assert.equal(caps.length, 2);
98
+
99
+ const wave = caps.find((c) => c.id === "wave_hello") as Capability;
100
+ assert.ok(wave, "wave_hello should be present");
101
+ assert.equal(wave.verb, "greet");
102
+ assert.equal(wave.interruptible, true);
103
+ assert.equal(wave.source?.kind, "skill");
104
+ if (wave.source?.kind === "skill") {
105
+ assert.equal(wave.source.skillId, "alpha");
106
+ assert.equal(wave.source.package, "agenticros-skill-alpha");
107
+ }
108
+ assert.equal(wave.implementation?.kind, "in_process", "default implementation should be in_process");
109
+
110
+ const patrol = caps.find((c) => c.id === "patrol_room") as Capability;
111
+ assert.equal(patrol.blocks_base, true);
112
+ } finally {
113
+ await rm(root, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ test("readSkillCapabilities: uses agenticros.id (not the package name) for the skill id", async () => {
118
+ const root = await mkdtemp(path.join(tmpdir(), "agenticros-caps-"));
119
+ try {
120
+ const skillDir = await makeSkillDir(root, "weird-named-repo", {
121
+ name: "@some-org/totally-different-name",
122
+ version: "0.0.1",
123
+ agenticros: {
124
+ id: "explicit-id",
125
+ capabilities: [{ id: "do_thing", verb: "do", description: "Do." }],
126
+ },
127
+ });
128
+ const caps = readSkillCapabilities(parseConfig({ skillPaths: [skillDir] }));
129
+ assert.equal(caps.length, 1);
130
+ assert.equal(caps[0].source?.kind, "skill");
131
+ if (caps[0].source?.kind === "skill") {
132
+ assert.equal(caps[0].source.skillId, "explicit-id");
133
+ assert.equal(caps[0].source.package, "@some-org/totally-different-name");
134
+ }
135
+ } finally {
136
+ await rm(root, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test("readSkillCapabilities: silently ignores non-skill packages", async () => {
141
+ const root = await mkdtemp(path.join(tmpdir(), "agenticros-caps-"));
142
+ try {
143
+ const dir = await makeSkillDir(root, "not-a-skill", {
144
+ name: "regular-package",
145
+ version: "0.0.1",
146
+ });
147
+ const caps = readSkillCapabilities(parseConfig({ skillPaths: [dir] }));
148
+ assert.equal(caps.length, 0);
149
+ } finally {
150
+ await rm(root, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ test("readSkillCapabilities: dedupes within a skill when an id appears twice in the array", async () => {
155
+ const root = await mkdtemp(path.join(tmpdir(), "agenticros-caps-"));
156
+ try {
157
+ const skillDir = await makeSkillDir(root, "skill-dupe", {
158
+ name: "agenticros-skill-dupe",
159
+ version: "0.0.1",
160
+ agenticros: {
161
+ id: "dupe",
162
+ capabilities: [
163
+ { id: "shared_id", verb: "first", description: "First." },
164
+ { id: "shared_id", verb: "second", description: "Duplicate (ignored)." },
165
+ { id: "extra", verb: "demo", description: "Unique." },
166
+ ],
167
+ },
168
+ });
169
+ const caps = readSkillCapabilities(parseConfig({ skillPaths: [skillDir] }));
170
+ const ids = caps.map((c) => c.id).sort();
171
+ assert.deepEqual(ids, ["extra", "shared_id"]);
172
+ const shared = caps.find((c) => c.id === "shared_id");
173
+ assert.equal(shared?.verb, "first", "first entry should win on duplicate id");
174
+ } finally {
175
+ await rm(root, { recursive: true, force: true });
176
+ }
177
+ });
178
+
179
+ test("readSkillCapabilities: drops entries without a valid id", async () => {
180
+ const root = await mkdtemp(path.join(tmpdir(), "agenticros-caps-"));
181
+ try {
182
+ const skillDir = await makeSkillDir(root, "skill-bad", {
183
+ name: "agenticros-skill-bad",
184
+ version: "0.0.1",
185
+ agenticros: {
186
+ id: "bad",
187
+ capabilities: [
188
+ { verb: "noid", description: "Missing id." },
189
+ { id: "", verb: "empty", description: "Empty id." },
190
+ { id: "ok", verb: "good", description: "Valid." },
191
+ "not_an_object",
192
+ null,
193
+ ],
194
+ },
195
+ });
196
+ const caps = readSkillCapabilities(parseConfig({ skillPaths: [skillDir] }));
197
+ assert.equal(caps.length, 1);
198
+ assert.equal(caps[0].id, "ok");
199
+ } finally {
200
+ await rm(root, { recursive: true, force: true });
201
+ }
202
+ });
203
+
204
+ test("readSkillCapabilities: skips packages with an `agenticros` block that lacks an `id`", async () => {
205
+ const root = await mkdtemp(path.join(tmpdir(), "agenticros-caps-"));
206
+ try {
207
+ const skillDir = await makeSkillDir(root, "skill-noid", {
208
+ name: "agenticros-skill-noid",
209
+ version: "0.0.1",
210
+ agenticros: {
211
+ capabilities: [{ id: "do_thing", verb: "do", description: "Do." }],
212
+ },
213
+ });
214
+ const caps = readSkillCapabilities(parseConfig({ skillPaths: [skillDir] }));
215
+ assert.equal(caps.length, 0);
216
+ } finally {
217
+ await rm(root, { recursive: true, force: true });
218
+ }
219
+ });
220
+
221
+ test("listAllCapabilities: built-ins first, then skill caps; counts add up", async () => {
222
+ const root = await mkdtemp(path.join(tmpdir(), "agenticros-caps-"));
223
+ try {
224
+ const skillDir = await makeSkillDir(root, "skill-extra", {
225
+ name: "agenticros-skill-extra",
226
+ version: "0.0.1",
227
+ agenticros: {
228
+ id: "extra",
229
+ capabilities: [
230
+ { id: "extra_one", verb: "demo", description: "One." },
231
+ { id: "extra_two", verb: "demo", description: "Two." },
232
+ ],
233
+ },
234
+ });
235
+ const config = parseConfig({ skillPaths: [skillDir] });
236
+ const caps = listAllCapabilities(config);
237
+
238
+ assert.equal(caps.length, BUILTIN_CAPABILITIES.length + 2);
239
+ // Built-ins are first.
240
+ for (let i = 0; i < BUILTIN_CAPABILITIES.length; i++) {
241
+ assert.equal(caps[i].id, BUILTIN_CAPABILITIES[i].id);
242
+ assert.equal(caps[i].source?.kind, "builtin");
243
+ }
244
+ // Skill caps follow.
245
+ const skillSlice = caps.slice(BUILTIN_CAPABILITIES.length);
246
+ assert.deepEqual(
247
+ skillSlice.map((c) => c.id).sort(),
248
+ ["extra_one", "extra_two"],
249
+ );
250
+ for (const c of skillSlice) assert.equal(c.source?.kind, "skill");
251
+ } finally {
252
+ await rm(root, { recursive: true, force: true });
253
+ }
254
+ });
255
+
256
+ test("listAllCapabilities: empty config returns only built-ins (no crash)", () => {
257
+ const caps = listAllCapabilities(parseConfig({}));
258
+ assert.equal(caps.length, BUILTIN_CAPABILITIES.length);
259
+ assert.ok(caps.every((c) => c.source?.kind === "builtin"));
260
+ });
261
+
262
+ test("readSkillCapabilities: missing skillPath does not throw", () => {
263
+ const config = parseConfig({ skillPaths: ["/nonexistent/path/to/nowhere"] });
264
+ const caps = readSkillCapabilities(config);
265
+ assert.deepEqual(caps, []);
266
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Unit tests for the Phase 1.d multi-robot discovery module.
3
+ *
4
+ * What these tests pin down (each one corresponds to a real failure mode
5
+ * the discovery layer has to defend against in production):
6
+ *
7
+ * - Topic detection picks up `/<ns>/cmd_vel` for every namespace and
8
+ * also the unnamespaced `/cmd_vel` (sim default).
9
+ * - topicCount aggregates corroborating topics under each namespace —
10
+ * this is what consumers use to rank a "live" namespace over a stale
11
+ * one with only cmd_vel left over.
12
+ * - UUID→robot-no-dashes rewrite: a config entry with a UUID-style
13
+ * namespace ("3946b404-...") MUST match a topic
14
+ * "/robot3946b404.../cmd_vel" on the wire. This is the rewrite the
15
+ * publish path applies, so discovery has to mirror it or
16
+ * configured_online will always be empty for UUID deployments.
17
+ * - configured_online / configured_offline / unknown_detected
18
+ * partition correctly — every configured robot lands in exactly
19
+ * one bucket; every detected namespace that doesn't match any
20
+ * config lands in unknown_detected.
21
+ * - Empty inputs: no topics + legacy config = legacy robot is
22
+ * configured_offline (it's configured but the wire is silent).
23
+ */
24
+ import { test } from "node:test";
25
+ import assert from "node:assert/strict";
26
+
27
+ import { parseConfig } from "../config.js";
28
+ import {
29
+ detectRobotsFromTopics,
30
+ discoverRobots,
31
+ effectiveCmdVelNamespace,
32
+ } from "../discovery.js";
33
+ import type { TopicInfo } from "../transport/types.js";
34
+
35
+ const T = (name: string, type = "geometry_msgs/msg/Twist"): TopicInfo => ({ name, type });
36
+
37
+ test("discovery: detectRobotsFromTopics finds /<ns>/cmd_vel and the unnamespaced /cmd_vel", () => {
38
+ const topics: TopicInfo[] = [
39
+ T("/robotA/cmd_vel"),
40
+ T("/robotB/cmd_vel"),
41
+ T("/cmd_vel"),
42
+ T("/rosout", "rcl_interfaces/msg/Log"),
43
+ ];
44
+ const detected = detectRobotsFromTopics(topics);
45
+ const ids = detected.map((d) => d.id).sort();
46
+ assert.deepEqual(ids, ["", "robotA", "robotB"]);
47
+ });
48
+
49
+ test("discovery: detectRobotsFromTopics accumulates topicCount under each namespace", () => {
50
+ const topics: TopicInfo[] = [
51
+ T("/robotA/cmd_vel"),
52
+ T("/robotA/odom", "nav_msgs/msg/Odometry"),
53
+ T("/robotA/joint_states", "sensor_msgs/msg/JointState"),
54
+ T("/robotA/camera/color/image_raw", "sensor_msgs/msg/Image"),
55
+ T("/robotB/cmd_vel"),
56
+ T("/robotB/odom", "nav_msgs/msg/Odometry"),
57
+ ];
58
+ const detected = detectRobotsFromTopics(topics);
59
+ const A = detected.find((d) => d.id === "robotA")!;
60
+ const B = detected.find((d) => d.id === "robotB")!;
61
+ assert.equal(A.topicCount, 4, "robotA has 4 topics under /robotA/");
62
+ assert.equal(B.topicCount, 2, "robotB has 2 topics under /robotB/");
63
+ });
64
+
65
+ test("discovery: detectRobotsFromTopics yields empty list for topic graph with no cmd_vel", () => {
66
+ const topics: TopicInfo[] = [
67
+ T("/rosout", "rcl_interfaces/msg/Log"),
68
+ T("/tf", "tf2_msgs/msg/TFMessage"),
69
+ ];
70
+ assert.deepEqual(detectRobotsFromTopics(topics), []);
71
+ });
72
+
73
+ test("discovery: effectiveCmdVelNamespace mirrors the publish-path UUID rewrite", () => {
74
+ assert.equal(
75
+ effectiveCmdVelNamespace("3946b404-c33e-4aa3-9a8d-16deb1c5c593"),
76
+ "robot3946b404c33e4aa39a8d16deb1c5c593",
77
+ "UUID-shaped namespace must collapse to robot<no-dashes>",
78
+ );
79
+ assert.equal(
80
+ effectiveCmdVelNamespace("robot_alpha"),
81
+ "robot_alpha",
82
+ "already-robot-prefixed namespace stays unchanged",
83
+ );
84
+ assert.equal(
85
+ effectiveCmdVelNamespace(""),
86
+ "",
87
+ "empty namespace stays empty (default ns)",
88
+ );
89
+ assert.equal(
90
+ effectiveCmdVelNamespace("Robot7"),
91
+ "Robot7",
92
+ "case-insensitive 'robot' prefix is preserved as-is",
93
+ );
94
+ });
95
+
96
+ test("discovery: discoverRobots classifies a UUID-style configured robot as online via the topic rewrite", () => {
97
+ // The robot's config namespace is the UUID, but on the wire it
98
+ // publishes to /robot<no-dashes>/cmd_vel. Without the rewrite, this
99
+ // would be misclassified as offline.
100
+ const cfg = parseConfig({
101
+ robot: { namespace: "3946b404-c33e-4aa3-9a8d-16deb1c5c593" },
102
+ });
103
+ const topics: TopicInfo[] = [
104
+ T("/robot3946b404c33e4aa39a8d16deb1c5c593/cmd_vel"),
105
+ T("/robot3946b404c33e4aa39a8d16deb1c5c593/odom", "nav_msgs/msg/Odometry"),
106
+ ];
107
+ const result = discoverRobots(topics, cfg);
108
+ assert.equal(result.configured_online.length, 1, "UUID robot must be classified as online");
109
+ assert.equal(result.configured_online[0]!.id, "3946b404-c33e-4aa3-9a8d-16deb1c5c593");
110
+ assert.equal(result.configured_offline.length, 0);
111
+ assert.equal(result.unknown_detected.length, 0);
112
+ assert.equal(result.detected[0]!.configuredRobotId, "3946b404-c33e-4aa3-9a8d-16deb1c5c593");
113
+ });
114
+
115
+ test("discovery: discoverRobots reports configured robots as offline when nothing's on the wire", () => {
116
+ const cfg = parseConfig({
117
+ robot: { namespace: "robot-alpha" },
118
+ });
119
+ const result = discoverRobots([], cfg);
120
+ assert.equal(result.detected.length, 0);
121
+ assert.equal(result.configured_online.length, 0);
122
+ assert.equal(result.configured_offline.length, 1, "single configured robot must be offline");
123
+ assert.equal(result.configured_offline[0]!.id, "robot-alpha");
124
+ assert.equal(result.unknown_detected.length, 0);
125
+ });
126
+
127
+ test("discovery: discoverRobots surfaces an unknown_detected entry for an on-wire robot not in config", () => {
128
+ const cfg = parseConfig({
129
+ robot: { namespace: "robot-alpha" },
130
+ });
131
+ const topics: TopicInfo[] = [
132
+ T("/robot-alpha/cmd_vel"),
133
+ T("/robot-beta/cmd_vel"), // not in config — must show up as unknown
134
+ T("/robot-beta/odom", "nav_msgs/msg/Odometry"),
135
+ ];
136
+ const result = discoverRobots(topics, cfg);
137
+ assert.equal(result.configured_online.length, 1);
138
+ assert.equal(result.configured_online[0]!.id, "robot-alpha");
139
+ assert.equal(result.unknown_detected.length, 1);
140
+ assert.equal(result.unknown_detected[0]!.id, "robot-beta");
141
+ assert.equal(
142
+ result.unknown_detected[0]!.topicCount,
143
+ 2,
144
+ "topicCount includes cmd_vel itself plus every corroborating topic under /<id>/",
145
+ );
146
+ assert.equal(result.unknown_detected[0]!.configuredRobotId, null);
147
+ });
148
+
149
+ test("discovery: discoverRobots partitions correctly with mixed online + offline + unknown", () => {
150
+ const cfg = parseConfig({
151
+ robots: [
152
+ { id: "alpha", namespace: "robot-alpha", default: true },
153
+ { id: "beta", namespace: "robot-beta" },
154
+ { id: "gamma", namespace: "robot-gamma" }, // offline
155
+ ],
156
+ });
157
+ const topics: TopicInfo[] = [
158
+ T("/robot-alpha/cmd_vel"),
159
+ T("/robot-beta/cmd_vel"),
160
+ T("/robot-delta/cmd_vel"), // unknown
161
+ ];
162
+ const result = discoverRobots(topics, cfg);
163
+ assert.equal(result.detected.length, 3, "three /cmd_vel topics → three detections");
164
+ assert.deepEqual(
165
+ result.configured_online.map((r) => r.id).sort(),
166
+ ["alpha", "beta"],
167
+ );
168
+ assert.deepEqual(
169
+ result.configured_offline.map((r) => r.id),
170
+ ["gamma"],
171
+ "gamma is configured but not on the wire",
172
+ );
173
+ assert.deepEqual(
174
+ result.unknown_detected.map((d) => d.id),
175
+ ["robot-delta"],
176
+ );
177
+ assert.equal(
178
+ result.detected.find((d) => d.id === "robot-alpha")!.configuredRobotId,
179
+ "alpha",
180
+ "alpha's detected entry is annotated with its config id",
181
+ );
182
+ assert.equal(
183
+ result.detected.find((d) => d.id === "robot-delta")!.configuredRobotId,
184
+ null,
185
+ "delta has no config match — configuredRobotId stays null",
186
+ );
187
+ });
188
+
189
+ test("discovery: discoverRobots echoes total_topics for diagnostics", () => {
190
+ const topics: TopicInfo[] = [
191
+ T("/robot-alpha/cmd_vel"),
192
+ T("/rosout", "rcl_interfaces/msg/Log"),
193
+ T("/tf", "tf2_msgs/msg/TFMessage"),
194
+ ];
195
+ const cfg = parseConfig({ robot: { namespace: "robot-alpha" } });
196
+ const result = discoverRobots(topics, cfg);
197
+ assert.equal(result.total_topics, 3);
198
+ });
199
+
200
+ test("discovery: the unnamespaced /cmd_vel (sim default) detects as id=''", () => {
201
+ const topics: TopicInfo[] = [T("/cmd_vel"), T("/odom", "nav_msgs/msg/Odometry")];
202
+ const detected = detectRobotsFromTopics(topics);
203
+ assert.equal(detected.length, 1);
204
+ assert.equal(detected[0]!.id, "", "default-namespace robot uses id=''");
205
+ assert.equal(detected[0]!.cmdVelTopic, "/cmd_vel");
206
+ });