agenticros 0.1.17 → 0.1.19

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 (100) 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/down.d.ts.map +1 -1
  10. package/dist/commands/down.js +55 -3
  11. package/dist/commands/down.js.map +1 -1
  12. package/dist/commands/robots.d.ts +57 -0
  13. package/dist/commands/robots.d.ts.map +1 -0
  14. package/dist/commands/robots.js +678 -0
  15. package/dist/commands/robots.js.map +1 -0
  16. package/dist/index.js +28 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/util/mcp-discovery.d.ts +57 -0
  19. package/dist/util/mcp-discovery.d.ts.map +1 -0
  20. package/dist/util/mcp-discovery.js +153 -0
  21. package/dist/util/mcp-discovery.js.map +1 -0
  22. package/dist/util/robot-config.d.ts +167 -0
  23. package/dist/util/robot-config.d.ts.map +1 -0
  24. package/dist/util/robot-config.js +375 -0
  25. package/dist/util/robot-config.js.map +1 -0
  26. package/dist/util/transport-shorthand.d.ts +66 -0
  27. package/dist/util/transport-shorthand.d.ts.map +1 -0
  28. package/dist/util/transport-shorthand.js +102 -0
  29. package/dist/util/transport-shorthand.js.map +1 -0
  30. package/package.json +2 -1
  31. package/runtime/BUNDLE.json +1 -1
  32. package/runtime/README.md +4 -0
  33. package/runtime/package.json +6 -1
  34. package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
  35. package/runtime/packages/agenticros/package.json +2 -1
  36. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
  37. package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
  38. package/runtime/packages/agenticros/src/service.ts +120 -2
  39. package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
  40. package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
  41. package/runtime/packages/agenticros/src/tools/index.ts +49 -9
  42. package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
  43. package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
  44. package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
  45. package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
  46. package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
  47. package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
  48. package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
  49. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
  50. package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
  51. package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
  52. package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
  53. package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
  54. package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
  55. package/runtime/packages/agenticros-claude-code/package.json +2 -1
  56. package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
  57. package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
  58. package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
  59. package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
  60. package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
  61. package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
  62. package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
  63. package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
  64. package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
  65. package/runtime/packages/agenticros-gemini/package.json +1 -0
  66. package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
  67. package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
  68. package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
  69. package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
  70. package/runtime/packages/core/package.json +1 -1
  71. package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
  72. package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
  73. package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
  74. package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
  75. package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
  76. package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
  77. package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
  78. package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
  79. package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
  80. package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
  81. package/runtime/packages/core/src/capabilities.ts +340 -0
  82. package/runtime/packages/core/src/config.ts +220 -38
  83. package/runtime/packages/core/src/discovery.ts +193 -0
  84. package/runtime/packages/core/src/find-robots-for.ts +164 -0
  85. package/runtime/packages/core/src/index.ts +67 -1
  86. package/runtime/packages/core/src/mission-registry.ts +142 -0
  87. package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
  88. package/runtime/packages/core/src/mission.ts +574 -0
  89. package/runtime/packages/core/src/planner/index.ts +564 -0
  90. package/runtime/packages/core/src/robots.ts +282 -0
  91. package/runtime/packages/core/src/topic-utils.ts +47 -9
  92. package/runtime/packages/core/src/transport-pool.ts +171 -0
  93. package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
  94. package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
  95. package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
  96. package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
  97. package/runtime/scripts/refresh-skill-deps.mjs +381 -0
  98. package/runtime/scripts/setup_gateway_plugin.sh +32 -5
  99. package/runtime/scripts/start_demo.sh +26 -3
  100. package/runtime/scripts/sync-skill-tools.mjs +67 -10
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Unit tests for the Phase 1.g rule-based mission planner.
3
+ *
4
+ * These tests pin the contract for `compileGoalToMission`:
5
+ *
6
+ * - Recognised verbs (find / take / measure / follow / drive / list)
7
+ * map to the right capability ids and extract structured inputs.
8
+ * - Compound "find X and drive toward it" emits a 2-step plan with
9
+ * the canonical template ref so the runner steers via the
10
+ * detected horizontal_offset.
11
+ * - Skill capabilities the runtime doesn't have are NOT fabricated
12
+ * — if `find_object` isn't in the registry, "find a chair" fails
13
+ * gracefully instead of pretending to run.
14
+ * - Failures surface a useful error + suggestions list so the
15
+ * agent can self-correct without an extra round-trip.
16
+ * - Multi-segment goals ("X and Y") compile each segment
17
+ * independently; segments that don't map are recorded as
18
+ * `unmatched_verbs` for explainability.
19
+ */
20
+
21
+ import { test } from "node:test";
22
+ import assert from "node:assert/strict";
23
+
24
+ import { compileGoalToMission } from "../planner/index.js";
25
+ import type { Capability } from "../capabilities.js";
26
+
27
+ const BASE_CAPS: Capability[] = [
28
+ { id: "drive_base", verb: "drive", description: "drive", source: { kind: "builtin" } },
29
+ { id: "take_snapshot", verb: "see", description: "see", source: { kind: "builtin" } },
30
+ { id: "measure_depth", verb: "measure", description: "measure", source: { kind: "builtin" } },
31
+ { id: "list_topics", verb: "introspect", description: "list", source: { kind: "builtin" } },
32
+ {
33
+ id: "find_object",
34
+ verb: "find",
35
+ description: "find",
36
+ source: { kind: "skill", skillId: "find", package: "agenticros-skill-find" },
37
+ },
38
+ {
39
+ id: "follow_person",
40
+ verb: "follow",
41
+ description: "follow",
42
+ source: { kind: "skill", skillId: "followme", package: "agenticros-skill-followme" },
43
+ },
44
+ ];
45
+
46
+ // --- Recognition: single-verb goals ---
47
+
48
+ test("planner: 'find a chair' compiles to a single find_object step", () => {
49
+ const res = compileGoalToMission("find a chair", BASE_CAPS);
50
+ assert.ok(res.mission, `expected mission, got error: ${res.error}`);
51
+ assert.equal(res.mission!.steps.length, 1);
52
+ assert.equal(res.mission!.steps[0].capability, "find_object");
53
+ assert.equal((res.mission!.steps[0].inputs as { target: string }).target, "chair");
54
+ assert.equal(res.mission!.goal, "find a chair");
55
+ assert.ok(res.mission!.name?.includes("find a chair"));
56
+ });
57
+
58
+ test("planner: 'where is the laptop' compiles to find_object with target=laptop", () => {
59
+ const res = compileGoalToMission("Where is the laptop?", BASE_CAPS);
60
+ assert.ok(res.mission);
61
+ assert.equal(res.mission!.steps[0].capability, "find_object");
62
+ assert.equal((res.mission!.steps[0].inputs as { target: string }).target, "laptop");
63
+ });
64
+
65
+ test("planner: 'look for a bottle' uses the 'look for' synonym", () => {
66
+ const res = compileGoalToMission("Look for a bottle", BASE_CAPS);
67
+ assert.ok(res.mission);
68
+ assert.equal((res.mission!.steps[0].inputs as { target: string }).target, "bottle");
69
+ });
70
+
71
+ test("planner: 'take a picture' maps to take_snapshot", () => {
72
+ const res = compileGoalToMission("take a picture", BASE_CAPS);
73
+ assert.ok(res.mission);
74
+ assert.equal(res.mission!.steps.length, 1);
75
+ assert.equal(res.mission!.steps[0].capability, "take_snapshot");
76
+ });
77
+
78
+ test("planner: 'what do you see' maps to take_snapshot", () => {
79
+ const res = compileGoalToMission("What do you see?", BASE_CAPS);
80
+ assert.ok(res.mission);
81
+ assert.equal(res.mission!.steps[0].capability, "take_snapshot");
82
+ });
83
+
84
+ test("planner: 'measure depth' maps to measure_depth", () => {
85
+ const res = compileGoalToMission("Measure depth", BASE_CAPS);
86
+ assert.ok(res.mission);
87
+ assert.equal(res.mission!.steps[0].capability, "measure_depth");
88
+ });
89
+
90
+ test("planner: 'how far away is the wall' maps to measure_depth", () => {
91
+ const res = compileGoalToMission("How far away is the wall?", BASE_CAPS);
92
+ assert.ok(res.mission);
93
+ assert.equal(res.mission!.steps[0].capability, "measure_depth");
94
+ });
95
+
96
+ test("planner: 'follow me' maps to follow_person", () => {
97
+ const res = compileGoalToMission("follow me", BASE_CAPS);
98
+ assert.ok(res.mission);
99
+ assert.equal(res.mission!.steps[0].capability, "follow_person");
100
+ });
101
+
102
+ test("planner: 'list topics' maps to list_topics", () => {
103
+ const res = compileGoalToMission("list topics", BASE_CAPS);
104
+ assert.ok(res.mission);
105
+ assert.equal(res.mission!.steps[0].capability, "list_topics");
106
+ });
107
+
108
+ // --- Drive parsing ---
109
+
110
+ test("planner: 'drive forward' produces positive linear_x with default speed", () => {
111
+ const res = compileGoalToMission("drive forward", BASE_CAPS);
112
+ assert.ok(res.mission);
113
+ const inputs = res.mission!.steps[0].inputs as { linear_x: number; angular_z: number };
114
+ assert.ok(inputs.linear_x > 0, `linear_x should be positive, got ${inputs.linear_x}`);
115
+ assert.equal(inputs.angular_z, 0);
116
+ });
117
+
118
+ test("planner: 'drive forward at 0.5 m/s' extracts the explicit speed", () => {
119
+ const res = compileGoalToMission("drive forward at 0.5 m/s", BASE_CAPS);
120
+ assert.ok(res.mission);
121
+ const inputs = res.mission!.steps[0].inputs as { linear_x: number };
122
+ assert.equal(inputs.linear_x, 0.5);
123
+ });
124
+
125
+ test("planner: 'drive backward' produces NEGATIVE linear_x", () => {
126
+ const res = compileGoalToMission("drive backward", BASE_CAPS);
127
+ assert.ok(res.mission);
128
+ const inputs = res.mission!.steps[0].inputs as { linear_x: number; angular_z: number };
129
+ assert.ok(inputs.linear_x < 0, `linear_x should be negative, got ${inputs.linear_x}`);
130
+ });
131
+
132
+ test("planner: 'turn left' produces positive angular_z", () => {
133
+ const res = compileGoalToMission("turn left", BASE_CAPS);
134
+ assert.ok(res.mission);
135
+ const inputs = res.mission!.steps[0].inputs as { linear_x: number; angular_z: number };
136
+ assert.equal(inputs.linear_x, 0);
137
+ assert.ok(inputs.angular_z > 0);
138
+ });
139
+
140
+ test("planner: 'turn right' produces NEGATIVE angular_z", () => {
141
+ const res = compileGoalToMission("turn right", BASE_CAPS);
142
+ assert.ok(res.mission);
143
+ const inputs = res.mission!.steps[0].inputs as { angular_z: number };
144
+ assert.ok(inputs.angular_z < 0);
145
+ });
146
+
147
+ test("planner: 'stop' produces a zero-velocity drive_base step", () => {
148
+ const res = compileGoalToMission("stop", BASE_CAPS);
149
+ assert.ok(res.mission);
150
+ const inputs = res.mission!.steps[0].inputs as { linear_x: number; angular_z: number };
151
+ assert.equal(inputs.linear_x, 0);
152
+ assert.equal(inputs.angular_z, 0);
153
+ });
154
+
155
+ test("planner: bare 'drive' (no direction) does NOT compile (avoids no-op steps)", () => {
156
+ // A drive without direction is ambiguous — the planner must NOT
157
+ // emit a useless zero-velocity step that's identical to "stop".
158
+ const res = compileGoalToMission("drive", BASE_CAPS);
159
+ assert.equal(res.mission, null);
160
+ assert.ok(res.error);
161
+ });
162
+
163
+ // --- Compound: find then approach ---
164
+
165
+ test("planner: 'find a chair and drive toward it' compiles to a 2-step plan with template ref", () => {
166
+ const res = compileGoalToMission("find a chair and drive toward it", BASE_CAPS);
167
+ assert.ok(res.mission, `expected mission, got error: ${res.error}`);
168
+ assert.equal(res.mission!.steps.length, 2);
169
+ // Step 1: find_object with target=chair
170
+ assert.equal(res.mission!.steps[0].id, "find");
171
+ assert.equal(res.mission!.steps[0].capability, "find_object");
172
+ assert.equal((res.mission!.steps[0].inputs as { target: string }).target, "chair");
173
+ // Step 2: drive_base with templated angular_z so the runner steers
174
+ // via the detected horizontal_offset.
175
+ assert.equal(res.mission!.steps[1].id, "approach");
176
+ assert.equal(res.mission!.steps[1].capability, "drive_base");
177
+ const driveInputs = res.mission!.steps[1].inputs as { linear_x: number; angular_z: string };
178
+ assert.ok(driveInputs.linear_x > 0, "approach should move forward");
179
+ assert.equal(
180
+ driveInputs.angular_z,
181
+ "{{find.outputs.horizontal_offset}}",
182
+ "angular_z must reference find_object's horizontal_offset output via the runner template",
183
+ );
184
+ });
185
+
186
+ test("planner: 'find a bottle and approach it' is the same 2-step pattern (approach synonym)", () => {
187
+ const res = compileGoalToMission("find a bottle and approach it", BASE_CAPS);
188
+ assert.ok(res.mission);
189
+ assert.equal(res.mission!.steps.length, 2);
190
+ assert.equal((res.mission!.steps[0].inputs as { target: string }).target, "bottle");
191
+ assert.equal(res.mission!.steps[1].capability, "drive_base");
192
+ });
193
+
194
+ // --- Multi-segment compounds (independent steps) ---
195
+
196
+ test("planner: 'take a picture and then measure depth' compiles to 2 independent steps", () => {
197
+ const res = compileGoalToMission("take a picture and then measure depth", BASE_CAPS);
198
+ assert.ok(res.mission, `expected mission, got error: ${res.error}`);
199
+ assert.equal(res.mission!.steps.length, 2);
200
+ assert.equal(res.mission!.steps[0].capability, "take_snapshot");
201
+ assert.equal(res.mission!.steps[1].capability, "measure_depth");
202
+ });
203
+
204
+ test("planner: 'follow me then take a picture' compiles to 2 sequential steps", () => {
205
+ const res = compileGoalToMission("follow me then take a picture", BASE_CAPS);
206
+ assert.ok(res.mission);
207
+ assert.equal(res.mission!.steps.length, 2);
208
+ assert.equal(res.mission!.steps[0].capability, "follow_person");
209
+ assert.equal(res.mission!.steps[1].capability, "take_snapshot");
210
+ });
211
+
212
+ // --- Capability availability (no fabrication) ---
213
+
214
+ test("planner: 'find a chair' fails when find_object isn't in the registry (no fabricated calls)", () => {
215
+ const noFind = BASE_CAPS.filter((c) => c.id !== "find_object");
216
+ const res = compileGoalToMission("find a chair", noFind);
217
+ assert.equal(res.mission, null);
218
+ assert.ok(res.error);
219
+ // The planner mentions available capabilities so the agent can pivot.
220
+ const allSuggestions = res.suggestions.join(" ");
221
+ assert.ok(allSuggestions.includes("Available capabilities"), `expected suggestions to list capabilities; got: ${allSuggestions}`);
222
+ });
223
+
224
+ test("planner: 'follow me' fails when follow_person isn't installed", () => {
225
+ const noFollow = BASE_CAPS.filter((c) => c.id !== "follow_person");
226
+ const res = compileGoalToMission("follow me", noFollow);
227
+ assert.equal(res.mission, null);
228
+ });
229
+
230
+ // --- Failure paths ---
231
+
232
+ test("planner: empty goal returns a clean error", () => {
233
+ const res = compileGoalToMission("", BASE_CAPS);
234
+ assert.equal(res.mission, null);
235
+ assert.ok(res.error?.toLowerCase().includes("empty"));
236
+ // Suggestions should still surface so the agent can recover.
237
+ assert.ok(res.suggestions.length > 0);
238
+ });
239
+
240
+ test("planner: whitespace-only goal is treated as empty", () => {
241
+ const res = compileGoalToMission(" \n\t ", BASE_CAPS);
242
+ assert.equal(res.mission, null);
243
+ assert.ok(res.error);
244
+ });
245
+
246
+ test("planner: 'paint the wall blue' (unrecognised verb) fails gracefully with suggestions", () => {
247
+ const res = compileGoalToMission("paint the wall blue", BASE_CAPS);
248
+ assert.equal(res.mission, null);
249
+ assert.ok(res.error);
250
+ assert.ok(res.suggestions.length > 0, "must surface suggestions on failure");
251
+ });
252
+
253
+ // --- Options pass-through ---
254
+
255
+ test("planner: options.robot_id is propagated onto the compiled mission", () => {
256
+ const res = compileGoalToMission("take a picture", BASE_CAPS, { robot_id: "robotA" });
257
+ assert.ok(res.mission);
258
+ assert.equal(res.mission!.robot_id, "robotA");
259
+ });
260
+
261
+ test("planner: options.mission_name overrides the default 'Goal: <goal>' label", () => {
262
+ const res = compileGoalToMission("take a picture", BASE_CAPS, { mission_name: "Snap" });
263
+ assert.ok(res.mission);
264
+ assert.equal(res.mission!.name, "Snap");
265
+ });
266
+
267
+ // --- Candidates surface ---
268
+
269
+ test("planner: candidates list contains the matched capability ids in declaration order", () => {
270
+ const res = compileGoalToMission("find a chair and drive toward it", BASE_CAPS);
271
+ assert.ok(res.mission);
272
+ assert.equal(res.candidates.length, 2);
273
+ assert.equal(res.candidates[0].capability_id, "find_object");
274
+ assert.equal(res.candidates[1].capability_id, "drive_base");
275
+ for (const c of res.candidates) {
276
+ assert.ok(c.confidence > 0 && c.confidence <= 1, `confidence should be in (0,1]; got ${c.confidence}`);
277
+ assert.ok(c.rationale.length > 0);
278
+ }
279
+ });
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Unit tests for the Phase 1.d multi-robot resolver.
3
+ *
4
+ * What these tests pin down (each one corresponds to a real failure mode
5
+ * the resolver has to defend against in production):
6
+ *
7
+ * - Backwards compat: existing single-robot configs (no `robots`
8
+ * array) must yield a synthesized one-entry list so older configs
9
+ * don't break and the chat agent doesn't see "no robots configured".
10
+ * - Synthesized fallback id derives from namespace when non-empty,
11
+ * defaults to "default" otherwise — important because the active id
12
+ * is what tool calls will key off in the next iteration.
13
+ * - Explicit `robots: []` takes precedence over the legacy single
14
+ * robot when non-empty.
15
+ * - `default: true` flag wins regardless of position. First-entry wins
16
+ * when no default flag.
17
+ * - `resolveRobot(robotId)` returns the matching entry, OR a
18
+ * descriptive error containing the known ids — the error text shape
19
+ * matters because the LLM uses it to self-correct.
20
+ * - `resolveRobot()` with no id returns the active robot, matching
21
+ * getActiveRobotId().
22
+ * - Edge: an explicit override on getActiveRobotId returns it
23
+ * verbatim (even if the id doesn't exist — discovery happens at
24
+ * resolveRobot time, not here).
25
+ */
26
+ import { test } from "node:test";
27
+ import assert from "node:assert/strict";
28
+
29
+ import { parseConfig } from "../config.js";
30
+ import {
31
+ listRobots,
32
+ resolveRobot,
33
+ getActiveRobotId,
34
+ getTransportConfigForRobot,
35
+ hasRobotTransportOverride,
36
+ type ResolvedRobot,
37
+ } from "../robots.js";
38
+
39
+ test("robots: empty config yields one synthesized 'default' entry (legacy fallback)", () => {
40
+ const cfg = parseConfig({});
41
+ const robots = listRobots(cfg);
42
+ assert.equal(robots.length, 1);
43
+ assert.equal(robots[0].id, "default");
44
+ assert.equal(robots[0].name, "Robot");
45
+ assert.equal(robots[0].namespace, "");
46
+ assert.equal(robots[0].source, "legacy");
47
+ });
48
+
49
+ test("robots: legacy single-robot config synthesizes id from namespace", () => {
50
+ const cfg = parseConfig({
51
+ robot: { name: "Spot", namespace: "robot-spot-1", cameraTopic: "/cam" },
52
+ });
53
+ const robots = listRobots(cfg);
54
+ assert.equal(robots.length, 1);
55
+ assert.equal(robots[0].id, "robot-spot-1");
56
+ assert.equal(robots[0].name, "Spot");
57
+ assert.equal(robots[0].namespace, "robot-spot-1");
58
+ assert.equal(robots[0].cameraTopic, "/cam");
59
+ assert.equal(robots[0].source, "legacy");
60
+ });
61
+
62
+ test("robots: explicit robots[] array overrides the legacy single-robot fallback", () => {
63
+ const cfg = parseConfig({
64
+ robot: { name: "Ignored", namespace: "ignored-ns" },
65
+ robots: [
66
+ { id: "alpha", name: "Alpha", namespace: "ns-alpha" },
67
+ { id: "beta", name: "Beta", namespace: "ns-beta" },
68
+ ],
69
+ });
70
+ const robots = listRobots(cfg);
71
+ assert.equal(robots.length, 2);
72
+ assert.deepEqual(
73
+ robots.map((r) => r.id),
74
+ ["alpha", "beta"],
75
+ );
76
+ for (const r of robots) assert.equal(r.source, "config");
77
+ });
78
+
79
+ test("robots: getActiveRobotId returns first entry when no default flag is set", () => {
80
+ const cfg = parseConfig({
81
+ robots: [
82
+ { id: "alpha", name: "Alpha" },
83
+ { id: "beta", name: "Beta" },
84
+ ],
85
+ });
86
+ assert.equal(getActiveRobotId(cfg), "alpha");
87
+ });
88
+
89
+ test("robots: getActiveRobotId picks the default-flagged entry regardless of position", () => {
90
+ const cfg = parseConfig({
91
+ robots: [
92
+ { id: "alpha", name: "Alpha" },
93
+ { id: "beta", name: "Beta", default: true },
94
+ { id: "gamma", name: "Gamma" },
95
+ ],
96
+ });
97
+ assert.equal(getActiveRobotId(cfg), "beta");
98
+ });
99
+
100
+ test("robots: getActiveRobotId honours explicit override", () => {
101
+ const cfg = parseConfig({
102
+ robots: [
103
+ { id: "alpha", name: "Alpha" },
104
+ { id: "beta", name: "Beta", default: true },
105
+ ],
106
+ });
107
+ assert.equal(getActiveRobotId(cfg, "alpha"), "alpha");
108
+ });
109
+
110
+ test("robots: getActiveRobotId trims whitespace from the override", () => {
111
+ const cfg = parseConfig({
112
+ robots: [{ id: "alpha", name: "Alpha" }],
113
+ });
114
+ assert.equal(getActiveRobotId(cfg, " alpha "), "alpha");
115
+ });
116
+
117
+ test("robots: getActiveRobotId ignores empty-string overrides (falls through to default rules)", () => {
118
+ const cfg = parseConfig({
119
+ robots: [
120
+ { id: "alpha", name: "Alpha" },
121
+ { id: "beta", name: "Beta", default: true },
122
+ ],
123
+ });
124
+ assert.equal(getActiveRobotId(cfg, ""), "beta");
125
+ assert.equal(getActiveRobotId(cfg, " "), "beta");
126
+ });
127
+
128
+ test("robots: resolveRobot() with no id returns the active robot", () => {
129
+ const cfg = parseConfig({
130
+ robots: [
131
+ { id: "alpha", name: "Alpha" },
132
+ { id: "beta", name: "Beta", default: true },
133
+ ],
134
+ });
135
+ const r = resolveRobot(cfg);
136
+ assert.equal(r.id, "beta");
137
+ assert.equal(r.name, "Beta");
138
+ });
139
+
140
+ test("robots: resolveRobot(id) returns the matching entry", () => {
141
+ const cfg = parseConfig({
142
+ robots: [
143
+ { id: "alpha", name: "Alpha", namespace: "ns-a" },
144
+ { id: "beta", name: "Beta", namespace: "ns-b" },
145
+ ],
146
+ });
147
+ const r: ResolvedRobot = resolveRobot(cfg, "beta");
148
+ assert.equal(r.id, "beta");
149
+ assert.equal(r.namespace, "ns-b");
150
+ });
151
+
152
+ test("robots: resolveRobot(unknown id) throws with known ids listed in the error message", () => {
153
+ const cfg = parseConfig({
154
+ robots: [
155
+ { id: "alpha", name: "Alpha" },
156
+ { id: "beta", name: "Beta" },
157
+ ],
158
+ });
159
+ assert.throws(
160
+ () => resolveRobot(cfg, "gamma"),
161
+ (err: Error) => {
162
+ assert.ok(
163
+ err.message.includes("gamma"),
164
+ `error should mention the unknown id (got: ${err.message})`,
165
+ );
166
+ assert.ok(
167
+ err.message.includes("alpha") && err.message.includes("beta"),
168
+ `error should list known ids (got: ${err.message})`,
169
+ );
170
+ assert.ok(
171
+ err.message.toLowerCase().includes("ros2_list_robots"),
172
+ `error should hint at ros2_list_robots for self-correction (got: ${err.message})`,
173
+ );
174
+ return true;
175
+ },
176
+ );
177
+ });
178
+
179
+ test("robots: resolveRobot trims whitespace from id arg", () => {
180
+ const cfg = parseConfig({
181
+ robots: [{ id: "alpha", name: "Alpha" }],
182
+ });
183
+ assert.equal(resolveRobot(cfg, " alpha ").id, "alpha");
184
+ });
185
+
186
+ test("robots: source tag distinguishes config vs legacy entries", () => {
187
+ const cfgLegacy = parseConfig({ robot: { namespace: "ns" } });
188
+ const cfgExplicit = parseConfig({ robots: [{ id: "x" }] });
189
+ assert.equal(listRobots(cfgLegacy)[0].source, "legacy");
190
+ assert.equal(listRobots(cfgExplicit)[0].source, "config");
191
+ });
192
+
193
+ // ─────────────────────────────────────────────────────────────────────────────
194
+ // Phase 1.d-resolve: per-robot transport override tests.
195
+ //
196
+ // These pin down a contract that matters for the multi-host fleet story —
197
+ // once adopted by adapters, a single host can simultaneously drive a local
198
+ // sim (mode: "local") AND a remote real robot reached via a router
199
+ // (mode: "zenoh" / "rosbridge"). The resolver alone doesn't materialise
200
+ // transports — it surfaces the right TransportConfig per robot id so an
201
+ // adapter pool can call `createTransport` on it.
202
+ // ─────────────────────────────────────────────────────────────────────────────
203
+
204
+ test("transport-override: schema accepts a per-robot override and round-trips", () => {
205
+ const cfg = parseConfig({
206
+ robots: [
207
+ {
208
+ id: "sim",
209
+ namespace: "sim_robot",
210
+ transport: { mode: "local", local: { domainId: 7 } },
211
+ },
212
+ ],
213
+ });
214
+ // Parsed shape preserves the override discriminant + sub-section.
215
+ const raw = cfg.robots[0]?.transport;
216
+ assert.ok(raw, "override must survive parseConfig");
217
+ assert.equal(raw!.mode, "local");
218
+ // Discriminant-narrowed access.
219
+ if (raw!.mode === "local") {
220
+ assert.equal(raw!.local?.domainId, 7);
221
+ }
222
+ });
223
+
224
+ test("transport-override: schema rejects a sub-section that doesn't match the mode", () => {
225
+ // A `mode: "zenoh"` override with a `local: {...}` sub-section is a
226
+ // typo we want to catch at parse time — otherwise the override would
227
+ // silently fall back to the global zenoh config and the user's
228
+ // intent would be lost.
229
+ assert.throws(() =>
230
+ parseConfig({
231
+ robots: [
232
+ {
233
+ id: "sim",
234
+ transport: { mode: "zenoh", local: { domainId: 1 } } as unknown as Record<string, unknown>,
235
+ },
236
+ ],
237
+ }),
238
+ );
239
+ });
240
+
241
+ test("transport-override: getTransportConfigForRobot returns the override when set", () => {
242
+ const cfg = parseConfig({
243
+ transport: { mode: "rosbridge" },
244
+ rosbridge: { url: "ws://default:9090" },
245
+ robots: [
246
+ { id: "real", namespace: "real" },
247
+ {
248
+ id: "sim",
249
+ namespace: "sim_robot",
250
+ transport: { mode: "local", local: { domainId: 7 } },
251
+ },
252
+ ],
253
+ });
254
+ const t = getTransportConfigForRobot(cfg, "sim");
255
+ assert.equal(t.mode, "local");
256
+ if (t.mode === "local") {
257
+ assert.equal(t.local?.domainId, 7, "override domainId must win");
258
+ }
259
+ });
260
+
261
+ test("transport-override: getTransportConfigForRobot falls back to the global transport when no override", () => {
262
+ const cfg = parseConfig({
263
+ transport: { mode: "zenoh" },
264
+ zenoh: { routerEndpoint: "ws://global:10000" },
265
+ robots: [{ id: "real", namespace: "real" }],
266
+ });
267
+ const t = getTransportConfigForRobot(cfg, "real");
268
+ assert.equal(t.mode, "zenoh");
269
+ if (t.mode === "zenoh") {
270
+ assert.equal(t.zenoh.routerEndpoint, "ws://global:10000");
271
+ }
272
+ });
273
+
274
+ test("transport-override: sub-section omitted in override inherits from top-level config", () => {
275
+ // The user wants `sim` to use zenoh BUT didn't repeat the
276
+ // routerEndpoint inside the override. The resolver must use the
277
+ // top-level zenoh config so they don't have to duplicate it.
278
+ const cfg = parseConfig({
279
+ transport: { mode: "rosbridge" }, // global mode is different — irrelevant here
280
+ zenoh: { routerEndpoint: "ws://shared:10000", domainId: 3 },
281
+ robots: [
282
+ {
283
+ id: "sim",
284
+ namespace: "sim_robot",
285
+ transport: { mode: "zenoh" }, // no `zenoh: {...}` sub-section
286
+ },
287
+ ],
288
+ });
289
+ const t = getTransportConfigForRobot(cfg, "sim");
290
+ assert.equal(t.mode, "zenoh");
291
+ if (t.mode === "zenoh") {
292
+ assert.equal(t.zenoh.routerEndpoint, "ws://shared:10000");
293
+ assert.equal(t.zenoh.domainId, 3);
294
+ }
295
+ });
296
+
297
+ test("transport-override: partial sub-section in override merges over top-level (per-field precedence)", () => {
298
+ // The override only customises routerEndpoint — domainId should
299
+ // continue to come from the top-level config. This is the most
300
+ // common real-world shape (override just the endpoint, keep
301
+ // everything else).
302
+ const cfg = parseConfig({
303
+ transport: { mode: "rosbridge" },
304
+ zenoh: { routerEndpoint: "ws://shared:10000", domainId: 3 },
305
+ robots: [
306
+ {
307
+ id: "field",
308
+ namespace: "field_robot",
309
+ transport: {
310
+ mode: "zenoh",
311
+ zenoh: { routerEndpoint: "ws://field-router:10000" },
312
+ },
313
+ },
314
+ ],
315
+ });
316
+ const t = getTransportConfigForRobot(cfg, "field");
317
+ assert.equal(t.mode, "zenoh");
318
+ if (t.mode === "zenoh") {
319
+ assert.equal(t.zenoh.routerEndpoint, "ws://field-router:10000", "override wins per-field");
320
+ assert.equal(t.zenoh.domainId, 3, "non-overridden fields inherit from global");
321
+ }
322
+ });
323
+
324
+ test("transport-override: legacy single-robot config still returns the global transport", () => {
325
+ // Backwards compat: when robots[] is empty, there's no override
326
+ // possible — fast path returns the global transport unmodified.
327
+ const cfg = parseConfig({
328
+ transport: { mode: "rosbridge" },
329
+ rosbridge: { url: "ws://default:9090" },
330
+ robot: { namespace: "legacy" },
331
+ });
332
+ const t = getTransportConfigForRobot(cfg, "legacy");
333
+ assert.equal(t.mode, "rosbridge");
334
+ if (t.mode === "rosbridge") {
335
+ assert.equal(t.rosbridge.url, "ws://default:9090");
336
+ }
337
+ });
338
+
339
+ test("transport-override: getTransportConfigForRobot() without id uses the active robot", () => {
340
+ const cfg = parseConfig({
341
+ transport: { mode: "local" },
342
+ robots: [
343
+ { id: "alpha", namespace: "alpha" },
344
+ {
345
+ id: "beta",
346
+ namespace: "beta",
347
+ default: true,
348
+ transport: { mode: "zenoh", zenoh: { routerEndpoint: "ws://beta:10000" } },
349
+ },
350
+ ],
351
+ });
352
+ const t = getTransportConfigForRobot(cfg); // no id ⇒ active = beta
353
+ assert.equal(t.mode, "zenoh");
354
+ });
355
+
356
+ test("transport-override: getTransportConfigForRobot throws on unknown robot_id (same contract as resolveRobot)", () => {
357
+ const cfg = parseConfig({
358
+ robots: [{ id: "alpha", namespace: "alpha" }],
359
+ });
360
+ assert.throws(
361
+ () => getTransportConfigForRobot(cfg, "gamma"),
362
+ /Unknown robot_id "gamma"/,
363
+ );
364
+ });
365
+
366
+ test("transport-override: hasRobotTransportOverride() reflects presence", () => {
367
+ const cfg = parseConfig({
368
+ robots: [
369
+ { id: "alpha", namespace: "alpha" },
370
+ {
371
+ id: "beta",
372
+ namespace: "beta",
373
+ transport: { mode: "local" },
374
+ },
375
+ ],
376
+ });
377
+ assert.equal(hasRobotTransportOverride(cfg, "alpha"), false);
378
+ assert.equal(hasRobotTransportOverride(cfg, "beta"), true);
379
+ });
380
+
381
+ test("transport-override: hasRobotTransportOverride() returns false (not throws) for unknown ids", () => {
382
+ // Robust against arbitrary tool args — adapters often want a cheap
383
+ // "should I take the pool path?" check before validating robot ids.
384
+ const cfg = parseConfig({
385
+ robots: [{ id: "alpha", namespace: "alpha" }],
386
+ });
387
+ assert.equal(hasRobotTransportOverride(cfg, "no-such"), false);
388
+ });
389
+
390
+ test("transport-override: ResolvedRobot is unchanged by overrides (transport is read via the helper, not the resolved record)", () => {
391
+ // Intentional design choice — ResolvedRobot stays lean. Tools that
392
+ // care about transport call getTransportConfigForRobot explicitly.
393
+ // This test guards that surface from accidental coupling.
394
+ const cfg = parseConfig({
395
+ robots: [
396
+ {
397
+ id: "sim",
398
+ namespace: "sim",
399
+ transport: { mode: "local", local: { domainId: 7 } },
400
+ },
401
+ ],
402
+ });
403
+ const r = resolveRobot(cfg, "sim");
404
+ assert.equal(r.id, "sim");
405
+ assert.equal((r as ResolvedRobot & { transport?: unknown }).transport, undefined);
406
+ });