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.
- package/dist/__tests__/robot-config.test.d.ts +2 -0
- package/dist/__tests__/robot-config.test.d.ts.map +1 -0
- package/dist/__tests__/robot-config.test.js +374 -0
- package/dist/__tests__/robot-config.test.js.map +1 -0
- package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
- package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
- package/dist/__tests__/transport-shorthand.test.js +88 -0
- package/dist/__tests__/transport-shorthand.test.js.map +1 -0
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +55 -3
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/robots.d.ts +57 -0
- package/dist/commands/robots.d.ts.map +1 -0
- package/dist/commands/robots.js +678 -0
- package/dist/commands/robots.js.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/util/mcp-discovery.d.ts +57 -0
- package/dist/util/mcp-discovery.d.ts.map +1 -0
- package/dist/util/mcp-discovery.js +153 -0
- package/dist/util/mcp-discovery.js.map +1 -0
- package/dist/util/robot-config.d.ts +167 -0
- package/dist/util/robot-config.d.ts.map +1 -0
- package/dist/util/robot-config.js +375 -0
- package/dist/util/robot-config.js.map +1 -0
- package/dist/util/transport-shorthand.d.ts +66 -0
- package/dist/util/transport-shorthand.d.ts.map +1 -0
- package/dist/util/transport-shorthand.js +102 -0
- package/dist/util/transport-shorthand.js.map +1 -0
- package/package.json +2 -1
- package/runtime/BUNDLE.json +1 -1
- package/runtime/README.md +4 -0
- package/runtime/package.json +6 -1
- package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
- package/runtime/packages/agenticros/package.json +2 -1
- package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
- package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
- package/runtime/packages/agenticros/src/service.ts +120 -2
- package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
- package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
- package/runtime/packages/agenticros/src/tools/index.ts +49 -9
- package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
- package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
- package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
- package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
- package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
- package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
- package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
- package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
- package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
- package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
- package/runtime/packages/agenticros-claude-code/package.json +2 -1
- package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
- package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
- package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
- package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
- package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
- package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
- package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
- package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
- package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
- package/runtime/packages/agenticros-gemini/package.json +1 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
- package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
- package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
- package/runtime/packages/core/package.json +1 -1
- package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
- package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
- package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
- package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
- package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
- package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
- package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
- package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
- package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
- package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
- package/runtime/packages/core/src/capabilities.ts +340 -0
- package/runtime/packages/core/src/config.ts +220 -38
- package/runtime/packages/core/src/discovery.ts +193 -0
- package/runtime/packages/core/src/find-robots-for.ts +164 -0
- package/runtime/packages/core/src/index.ts +67 -1
- package/runtime/packages/core/src/mission-registry.ts +142 -0
- package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
- package/runtime/packages/core/src/mission.ts +574 -0
- package/runtime/packages/core/src/planner/index.ts +564 -0
- package/runtime/packages/core/src/robots.ts +282 -0
- package/runtime/packages/core/src/topic-utils.ts +47 -9
- package/runtime/packages/core/src/transport-pool.ts +171 -0
- package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
- package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
- package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
- package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
- package/runtime/scripts/refresh-skill-deps.mjs +381 -0
- package/runtime/scripts/setup_gateway_plugin.sh +32 -5
- package/runtime/scripts/start_demo.sh +26 -3
- package/runtime/scripts/sync-skill-tools.mjs +67 -10
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for Phase 1.e `findRobotsFor`.
|
|
3
|
+
*
|
|
4
|
+
* What these pin down (each test corresponds to a real failure mode in
|
|
5
|
+
* the multi-robot fleet UX):
|
|
6
|
+
*
|
|
7
|
+
* - capability filter: matches against the global registry by default;
|
|
8
|
+
* a per-robot allowlist OVERRIDES the global registry (heterogeneous
|
|
9
|
+
* fleets); a capability absent from both filters the robot out.
|
|
10
|
+
* - kind filter: case-insensitive, exact match, no fuzzy matching
|
|
11
|
+
* (`amr` doesn't match `AMR Pro` — keeps the surface tiny).
|
|
12
|
+
* - online filter: needs onlineIds; when true keeps only online; when
|
|
13
|
+
* false keeps only offline; when omitted does not filter.
|
|
14
|
+
* - ranking: explicit per-robot capability match outscores inherited;
|
|
15
|
+
* online outscores offline; ties broken by config declaration order.
|
|
16
|
+
* - error path: query.online set + no onlineIds throws so adapters
|
|
17
|
+
* never silently skip the filter.
|
|
18
|
+
* - back-compat: a config with no robots[] still works (the legacy
|
|
19
|
+
* single robot is included with kind="amr" and all-false sensors).
|
|
20
|
+
*/
|
|
21
|
+
import { test } from "node:test";
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
|
|
24
|
+
import { parseConfig } from "../config.js";
|
|
25
|
+
import { findRobotsFor } from "../find-robots-for.js";
|
|
26
|
+
|
|
27
|
+
function configWith(robots: unknown[]) {
|
|
28
|
+
return parseConfig({ robots });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test("findRobotsFor: no query → returns the whole fleet in config order", () => {
|
|
32
|
+
const cfg = configWith([
|
|
33
|
+
{ id: "alpha", namespace: "alpha-ns" },
|
|
34
|
+
{ id: "beta", namespace: "beta-ns" },
|
|
35
|
+
]);
|
|
36
|
+
const out = findRobotsFor(cfg, {});
|
|
37
|
+
assert.equal(out.total, 2);
|
|
38
|
+
assert.equal(out.robots[0].robot.id, "alpha");
|
|
39
|
+
assert.equal(out.robots[1].robot.id, "beta");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("findRobotsFor: capability matches against the global registry by default", () => {
|
|
43
|
+
// `drive_base` is a built-in capability so both robots inherit it.
|
|
44
|
+
const cfg = configWith([
|
|
45
|
+
{ id: "alpha", namespace: "alpha-ns" },
|
|
46
|
+
{ id: "beta", namespace: "beta-ns" },
|
|
47
|
+
]);
|
|
48
|
+
const out = findRobotsFor(cfg, { capability: "drive_base" });
|
|
49
|
+
assert.equal(out.total, 2);
|
|
50
|
+
for (const m of out.robots) {
|
|
51
|
+
assert.equal(
|
|
52
|
+
m.matched_capability_explicitly,
|
|
53
|
+
false,
|
|
54
|
+
"no per-robot allowlist means the match is inherited",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("findRobotsFor: capability absent from global registry filters everyone out", () => {
|
|
60
|
+
const cfg = configWith([
|
|
61
|
+
{ id: "alpha", namespace: "alpha-ns" },
|
|
62
|
+
{ id: "beta", namespace: "beta-ns" },
|
|
63
|
+
]);
|
|
64
|
+
const out = findRobotsFor(cfg, { capability: "no_such_capability" });
|
|
65
|
+
assert.equal(out.total, 0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("findRobotsFor: per-robot capability allowlist OVERRIDES the global registry", () => {
|
|
69
|
+
// beta declares an allowlist that includes `arm_grasp` only.
|
|
70
|
+
// alpha has no allowlist → falls back to the global registry which
|
|
71
|
+
// does NOT contain `arm_grasp` → alpha filtered out.
|
|
72
|
+
const cfg = configWith([
|
|
73
|
+
{ id: "alpha", namespace: "alpha-ns" },
|
|
74
|
+
{
|
|
75
|
+
id: "beta",
|
|
76
|
+
namespace: "beta-ns",
|
|
77
|
+
capabilities: ["arm_grasp"],
|
|
78
|
+
},
|
|
79
|
+
]);
|
|
80
|
+
const out = findRobotsFor(cfg, { capability: "arm_grasp" });
|
|
81
|
+
assert.equal(out.total, 1);
|
|
82
|
+
assert.equal(out.robots[0].robot.id, "beta");
|
|
83
|
+
assert.equal(out.robots[0].matched_capability_explicitly, true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("findRobotsFor: per-robot allowlist also filters out global capabilities NOT in the list", () => {
|
|
87
|
+
// beta's allowlist excludes `drive_base` — even though it's a builtin,
|
|
88
|
+
// beta declared a tighter set. So `drive_base` only matches alpha.
|
|
89
|
+
const cfg = configWith([
|
|
90
|
+
{ id: "alpha", namespace: "alpha-ns" },
|
|
91
|
+
{
|
|
92
|
+
id: "beta",
|
|
93
|
+
namespace: "beta-ns",
|
|
94
|
+
capabilities: ["arm_grasp"],
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
const out = findRobotsFor(cfg, { capability: "drive_base" });
|
|
98
|
+
assert.equal(out.total, 1);
|
|
99
|
+
assert.equal(out.robots[0].robot.id, "alpha");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("findRobotsFor: kind filter is case-insensitive and exact-match", () => {
|
|
103
|
+
const cfg = configWith([
|
|
104
|
+
{ id: "alpha", namespace: "alpha-ns", kind: "amr" },
|
|
105
|
+
{ id: "beta", namespace: "beta-ns", kind: "arm" },
|
|
106
|
+
{ id: "gamma", namespace: "gamma-ns", kind: "drone" },
|
|
107
|
+
]);
|
|
108
|
+
const amrs = findRobotsFor(cfg, { kind: "AMR" });
|
|
109
|
+
assert.equal(amrs.total, 1);
|
|
110
|
+
assert.equal(amrs.robots[0].robot.id, "alpha");
|
|
111
|
+
|
|
112
|
+
const drones = findRobotsFor(cfg, { kind: "drone" });
|
|
113
|
+
assert.equal(drones.total, 1);
|
|
114
|
+
assert.equal(drones.robots[0].robot.id, "gamma");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("findRobotsFor: kind filter rejects non-exact matches (no fuzzy)", () => {
|
|
118
|
+
const cfg = configWith([{ id: "alpha", namespace: "alpha-ns", kind: "amr-pro" }]);
|
|
119
|
+
// Looking for "amr" — "amr-pro" is NOT a match. Phase 1.e is exact.
|
|
120
|
+
assert.equal(findRobotsFor(cfg, { kind: "amr" }).total, 0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("findRobotsFor: online=true keeps only robots in the onlineIds set", () => {
|
|
124
|
+
const cfg = configWith([
|
|
125
|
+
{ id: "alpha", namespace: "alpha-ns" },
|
|
126
|
+
{ id: "beta", namespace: "beta-ns" },
|
|
127
|
+
{ id: "gamma", namespace: "gamma-ns" },
|
|
128
|
+
]);
|
|
129
|
+
const onlineIds = new Set(["alpha", "gamma"]);
|
|
130
|
+
const out = findRobotsFor(cfg, { online: true }, onlineIds);
|
|
131
|
+
const ids = out.robots.map((m) => m.robot.id);
|
|
132
|
+
assert.deepEqual(ids, ["alpha", "gamma"]);
|
|
133
|
+
assert.equal(out.robots.every((m) => m.online === true), true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("findRobotsFor: online=false keeps only robots NOT in onlineIds", () => {
|
|
137
|
+
const cfg = configWith([
|
|
138
|
+
{ id: "alpha", namespace: "alpha-ns" },
|
|
139
|
+
{ id: "beta", namespace: "beta-ns" },
|
|
140
|
+
]);
|
|
141
|
+
const out = findRobotsFor(cfg, { online: false }, new Set(["alpha"]));
|
|
142
|
+
assert.equal(out.total, 1);
|
|
143
|
+
assert.equal(out.robots[0].robot.id, "beta");
|
|
144
|
+
assert.equal(out.robots[0].online, false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("findRobotsFor: providing onlineIds without query.online still annotates online status", () => {
|
|
148
|
+
// A common UX pattern: "show me everyone, but tell me which are online."
|
|
149
|
+
const cfg = configWith([
|
|
150
|
+
{ id: "alpha", namespace: "alpha-ns" },
|
|
151
|
+
{ id: "beta", namespace: "beta-ns" },
|
|
152
|
+
]);
|
|
153
|
+
const out = findRobotsFor(cfg, {}, new Set(["alpha"]));
|
|
154
|
+
const alpha = out.robots.find((m) => m.robot.id === "alpha");
|
|
155
|
+
const beta = out.robots.find((m) => m.robot.id === "beta");
|
|
156
|
+
assert.equal(alpha?.online, true);
|
|
157
|
+
assert.equal(beta?.online, false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("findRobotsFor: online filter with no onlineIds throws (caller bug guard)", () => {
|
|
161
|
+
const cfg = configWith([{ id: "alpha", namespace: "alpha-ns" }]);
|
|
162
|
+
assert.throws(() => findRobotsFor(cfg, { online: true }), /onlineIds was not provided/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("findRobotsFor: ranking — explicit-allowlist match beats inherited match", () => {
|
|
166
|
+
const cfg = configWith([
|
|
167
|
+
{ id: "inherited", namespace: "inh-ns" }, // inherits drive_base from registry
|
|
168
|
+
{
|
|
169
|
+
id: "explicit",
|
|
170
|
+
namespace: "exp-ns",
|
|
171
|
+
capabilities: ["drive_base"], // explicitly declares it
|
|
172
|
+
},
|
|
173
|
+
]);
|
|
174
|
+
const out = findRobotsFor(cfg, { capability: "drive_base" });
|
|
175
|
+
assert.equal(out.total, 2);
|
|
176
|
+
assert.equal(
|
|
177
|
+
out.robots[0].robot.id,
|
|
178
|
+
"explicit",
|
|
179
|
+
"explicit allowlist match should rank above inherited",
|
|
180
|
+
);
|
|
181
|
+
assert.equal(out.robots[1].robot.id, "inherited");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("findRobotsFor: ranking — online beats offline when query.online is true", () => {
|
|
185
|
+
const cfg = configWith([
|
|
186
|
+
{ id: "offline", namespace: "off-ns" },
|
|
187
|
+
{ id: "online", namespace: "on-ns" },
|
|
188
|
+
]);
|
|
189
|
+
// We're not filtering by online (so both kept), but ranking should
|
|
190
|
+
// still surface the online one first.
|
|
191
|
+
const out = findRobotsFor(cfg, {}, new Set(["online"]));
|
|
192
|
+
assert.equal(out.robots[0].robot.id, "online");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("findRobotsFor: ranking — ties broken by config declaration order (stable)", () => {
|
|
196
|
+
const cfg = configWith([
|
|
197
|
+
{ id: "first", namespace: "first-ns" },
|
|
198
|
+
{ id: "second", namespace: "second-ns" },
|
|
199
|
+
{ id: "third", namespace: "third-ns" },
|
|
200
|
+
]);
|
|
201
|
+
const out = findRobotsFor(cfg, {});
|
|
202
|
+
assert.deepEqual(
|
|
203
|
+
out.robots.map((m) => m.robot.id),
|
|
204
|
+
["first", "second", "third"],
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("findRobotsFor: back-compat — empty robots[] still returns the legacy entry", () => {
|
|
209
|
+
// No robots[], just the legacy single-robot block. The resolver should
|
|
210
|
+
// synthesize one match with kind=amr and all-false sensors so that
|
|
211
|
+
// existing single-robot deployments work with `find_robots_for`.
|
|
212
|
+
const cfg = parseConfig({
|
|
213
|
+
robot: { name: "Legacy", namespace: "legacy-ns" },
|
|
214
|
+
});
|
|
215
|
+
const out = findRobotsFor(cfg, {});
|
|
216
|
+
assert.equal(out.total, 1);
|
|
217
|
+
assert.equal(out.robots[0].robot.id, "legacy-ns");
|
|
218
|
+
assert.equal(out.robots[0].robot.kind, "amr");
|
|
219
|
+
assert.equal(out.robots[0].robot.sensors.has_realsense, false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("findRobotsFor: combined filters — capability AND kind AND online", () => {
|
|
223
|
+
const cfg = configWith([
|
|
224
|
+
{
|
|
225
|
+
id: "amr-online",
|
|
226
|
+
namespace: "ao-ns",
|
|
227
|
+
kind: "amr",
|
|
228
|
+
capabilities: ["follow_person"],
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: "amr-offline",
|
|
232
|
+
namespace: "aoff-ns",
|
|
233
|
+
kind: "amr",
|
|
234
|
+
capabilities: ["follow_person"],
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: "arm-online",
|
|
238
|
+
namespace: "arm-ns",
|
|
239
|
+
kind: "arm",
|
|
240
|
+
capabilities: ["arm_grasp"],
|
|
241
|
+
},
|
|
242
|
+
]);
|
|
243
|
+
const out = findRobotsFor(
|
|
244
|
+
cfg,
|
|
245
|
+
{ capability: "follow_person", kind: "amr", online: true },
|
|
246
|
+
new Set(["amr-online", "arm-online"]),
|
|
247
|
+
);
|
|
248
|
+
assert.equal(out.total, 1);
|
|
249
|
+
assert.equal(out.robots[0].robot.id, "amr-online");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("findRobotsFor: result.query echoes only the filters actually applied", () => {
|
|
253
|
+
const cfg = configWith([{ id: "alpha", namespace: "alpha-ns" }]);
|
|
254
|
+
const out = findRobotsFor(cfg, { kind: "AMR" });
|
|
255
|
+
assert.deepEqual(out.query, { kind: "amr" }, "kind is lowercased in the echo");
|
|
256
|
+
assert.equal("capability" in out.query, false, "absent fields aren't included");
|
|
257
|
+
assert.equal("online" in out.query, false);
|
|
258
|
+
});
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for Phase 1.f mission cancellation + transcripts.
|
|
3
|
+
*
|
|
4
|
+
* What these pin down (one test per behavioural contract the adapters
|
|
5
|
+
* and LLMs depend on):
|
|
6
|
+
*
|
|
7
|
+
* Cancellation
|
|
8
|
+
* ------------
|
|
9
|
+
* - A token flipped BEFORE the first step cancels every step (no
|
|
10
|
+
* dispatcher call).
|
|
11
|
+
* - A token flipped between steps lets the in-flight step finish,
|
|
12
|
+
* then marks the rest "cancelled".
|
|
13
|
+
* - `MissionResult.status` is "cancelled" when at least one step
|
|
14
|
+
* was preempted.
|
|
15
|
+
* - `cancellation_reason` from the token is bubbled into the result.
|
|
16
|
+
* - The `cancelled` step has `duration_ms=0` and a `message`
|
|
17
|
+
* containing the reason.
|
|
18
|
+
*
|
|
19
|
+
* Transcripts
|
|
20
|
+
* -----------
|
|
21
|
+
* - The sink fires once per step (including skipped + cancelled).
|
|
22
|
+
* - Entries carry mission_id, step_index, step_total, adapter, and
|
|
23
|
+
* a clone of the MissionStepResult.
|
|
24
|
+
* - A throwing transcript sink does NOT abort the mission (best-effort
|
|
25
|
+
* contract per the runner's header).
|
|
26
|
+
* - An async transcript sink that rejects also doesn't propagate.
|
|
27
|
+
*
|
|
28
|
+
* Registry
|
|
29
|
+
* --------
|
|
30
|
+
* - register() returns a usable cancellation token; cancel() flips it.
|
|
31
|
+
* - cancel() on an unknown id reports found:false (idempotent).
|
|
32
|
+
* - cancel() twice on the same id is idempotent (no throw, no double
|
|
33
|
+
* cancellation_reason override unless a new reason was supplied).
|
|
34
|
+
* - dispose() removes the entry so subsequent cancel() reports found:false.
|
|
35
|
+
* - generateMissionId() produces unique ids.
|
|
36
|
+
* - missionTranscriptNamespace(id) === "mission:<id>".
|
|
37
|
+
*/
|
|
38
|
+
import { test } from "node:test";
|
|
39
|
+
import assert from "node:assert/strict";
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
runMission,
|
|
43
|
+
type CapabilityToolBindings,
|
|
44
|
+
type Mission,
|
|
45
|
+
type MissionToolDispatcher,
|
|
46
|
+
type MissionTranscriptEntry,
|
|
47
|
+
} from "../mission.js";
|
|
48
|
+
import {
|
|
49
|
+
MissionRegistry,
|
|
50
|
+
generateMissionId,
|
|
51
|
+
missionTranscriptNamespace,
|
|
52
|
+
} from "../mission-registry.js";
|
|
53
|
+
import type { Capability } from "../capabilities.js";
|
|
54
|
+
|
|
55
|
+
const CAPS: Capability[] = [
|
|
56
|
+
{ id: "drive_base", verb: "drive", description: "drive", source: { kind: "builtin" } },
|
|
57
|
+
{ id: "take_snapshot", verb: "see", description: "see", source: { kind: "builtin" } },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const BINDINGS: CapabilityToolBindings = {
|
|
61
|
+
drive_base: {
|
|
62
|
+
tool: "ros2_publish",
|
|
63
|
+
buildArgs: (inputs) => ({ topic: "cmd_vel", msg: inputs }),
|
|
64
|
+
},
|
|
65
|
+
take_snapshot: {
|
|
66
|
+
tool: "ros2_camera_snapshot",
|
|
67
|
+
buildArgs: () => ({}),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function makeDispatcher(
|
|
72
|
+
responses: Record<string, { text: string; outputs?: Record<string, unknown>; isError?: boolean }>,
|
|
73
|
+
log: Array<{ tool: string; args: Record<string, unknown> }>,
|
|
74
|
+
): MissionToolDispatcher {
|
|
75
|
+
return async (tool, args) => {
|
|
76
|
+
log.push({ tool, args });
|
|
77
|
+
return responses[tool] ?? { text: `(no stub for ${tool})` };
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
// Cancellation
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
test("cancellation: token flipped before run cancels every step (zero dispatcher calls)", async () => {
|
|
86
|
+
const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
|
|
87
|
+
const dispatch = makeDispatcher({}, log);
|
|
88
|
+
const cancellation = { cancelled: true, reason: "user pressed stop" };
|
|
89
|
+
const mission: Mission = {
|
|
90
|
+
name: "drive 3x",
|
|
91
|
+
steps: [
|
|
92
|
+
{ id: "a", capability: "drive_base", inputs: { linear_x: 0.1 } },
|
|
93
|
+
{ id: "b", capability: "drive_base", inputs: { linear_x: 0.2 } },
|
|
94
|
+
{ id: "c", capability: "drive_base", inputs: { linear_x: 0.3 } },
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
const result = await runMission(mission, CAPS, BINDINGS, dispatch, {
|
|
98
|
+
mission_id: "mn_test",
|
|
99
|
+
cancellation,
|
|
100
|
+
});
|
|
101
|
+
assert.equal(result.status, "cancelled");
|
|
102
|
+
assert.equal(result.steps_run, 0, "no steps executed when cancelled up-front");
|
|
103
|
+
assert.equal(log.length, 0);
|
|
104
|
+
assert.equal(result.cancellation_reason, "user pressed stop");
|
|
105
|
+
for (const s of result.steps) {
|
|
106
|
+
assert.equal(s.status, "cancelled");
|
|
107
|
+
assert.equal(s.duration_ms, 0);
|
|
108
|
+
assert.ok(s.message?.includes("user pressed stop"));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("cancellation: token flipped mid-run preempts only remaining steps", async () => {
|
|
113
|
+
// The dispatcher flips the token while step "a" is in flight. Step
|
|
114
|
+
// "a" should still finish ok; "b" and "c" should be cancelled.
|
|
115
|
+
const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
|
|
116
|
+
const cancellation = { cancelled: false };
|
|
117
|
+
const dispatch: MissionToolDispatcher = async (tool, args) => {
|
|
118
|
+
log.push({ tool, args });
|
|
119
|
+
if (tool === "ros2_publish" && (args as Record<string, unknown>).msg) {
|
|
120
|
+
const msg = (args as Record<string, unknown>).msg as Record<string, unknown>;
|
|
121
|
+
if (msg.linear_x === 0.1) cancellation.cancelled = true;
|
|
122
|
+
}
|
|
123
|
+
return { text: "ok" };
|
|
124
|
+
};
|
|
125
|
+
const mission: Mission = {
|
|
126
|
+
steps: [
|
|
127
|
+
{ id: "a", capability: "drive_base", inputs: { linear_x: 0.1 } },
|
|
128
|
+
{ id: "b", capability: "drive_base", inputs: { linear_x: 0.2 } },
|
|
129
|
+
{ id: "c", capability: "drive_base", inputs: { linear_x: 0.3 } },
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
const result = await runMission(mission, CAPS, BINDINGS, dispatch, {
|
|
133
|
+
mission_id: "mn_test",
|
|
134
|
+
cancellation,
|
|
135
|
+
});
|
|
136
|
+
assert.equal(result.status, "cancelled");
|
|
137
|
+
assert.equal(result.steps[0].status, "ok", "the in-flight step finished");
|
|
138
|
+
assert.equal(result.steps[1].status, "cancelled");
|
|
139
|
+
assert.equal(result.steps[2].status, "cancelled");
|
|
140
|
+
assert.equal(result.steps_run, 1);
|
|
141
|
+
assert.equal(log.length, 1, "only step 'a' hit the dispatcher");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("cancellation: cancellation_reason defaults to 'cancelled' when token doesn't carry one", async () => {
|
|
145
|
+
const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
|
|
146
|
+
const dispatch = makeDispatcher({}, log);
|
|
147
|
+
const mission: Mission = { steps: [{ id: "a", capability: "drive_base" }] };
|
|
148
|
+
const result = await runMission(mission, CAPS, BINDINGS, dispatch, {
|
|
149
|
+
mission_id: "x",
|
|
150
|
+
cancellation: { cancelled: true },
|
|
151
|
+
});
|
|
152
|
+
assert.equal(result.cancellation_reason, "cancelled");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("cancellation: mission_id round-trips into MissionResult", async () => {
|
|
156
|
+
const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
|
|
157
|
+
const dispatch = makeDispatcher({ ros2_publish: { text: "ok" } }, log);
|
|
158
|
+
const result = await runMission(
|
|
159
|
+
{ steps: [{ id: "a", capability: "drive_base" }] },
|
|
160
|
+
CAPS,
|
|
161
|
+
BINDINGS,
|
|
162
|
+
dispatch,
|
|
163
|
+
{ mission_id: "mn_round_trip" },
|
|
164
|
+
);
|
|
165
|
+
assert.equal(result.mission_id, "mn_round_trip");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
// Transcripts
|
|
170
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
test("transcript: sink fires once per step including ok / cancelled", async () => {
|
|
173
|
+
const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
|
|
174
|
+
const cancellation = { cancelled: false };
|
|
175
|
+
const dispatch: MissionToolDispatcher = async (tool, args) => {
|
|
176
|
+
log.push({ tool, args });
|
|
177
|
+
cancellation.cancelled = true;
|
|
178
|
+
return { text: "ok" };
|
|
179
|
+
};
|
|
180
|
+
const entries: MissionTranscriptEntry[] = [];
|
|
181
|
+
const transcript = (e: MissionTranscriptEntry) => {
|
|
182
|
+
entries.push(e);
|
|
183
|
+
};
|
|
184
|
+
const mission: Mission = {
|
|
185
|
+
name: "drive then snap",
|
|
186
|
+
steps: [
|
|
187
|
+
{ id: "drive", capability: "drive_base", inputs: { linear_x: 0.1 } },
|
|
188
|
+
{ id: "snap", capability: "take_snapshot" },
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
await runMission(mission, CAPS, BINDINGS, dispatch, {
|
|
192
|
+
mission_id: "mn_t",
|
|
193
|
+
cancellation,
|
|
194
|
+
transcript,
|
|
195
|
+
adapter: "test-adapter",
|
|
196
|
+
});
|
|
197
|
+
assert.equal(entries.length, 2);
|
|
198
|
+
assert.equal(entries[0].step_index, 0);
|
|
199
|
+
assert.equal(entries[0].step_total, 2);
|
|
200
|
+
assert.equal(entries[0].mission_id, "mn_t");
|
|
201
|
+
assert.equal(entries[0].adapter, "test-adapter");
|
|
202
|
+
assert.equal(entries[0].mission_name, "drive then snap");
|
|
203
|
+
assert.equal(entries[0].result.status, "ok");
|
|
204
|
+
assert.equal(entries[1].result.status, "cancelled");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("transcript: a throwing sink does NOT abort the mission (best-effort)", async () => {
|
|
208
|
+
const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
|
|
209
|
+
const dispatch = makeDispatcher({ ros2_publish: { text: "ok" } }, log);
|
|
210
|
+
const result = await runMission(
|
|
211
|
+
{ steps: [{ id: "a", capability: "drive_base" }] },
|
|
212
|
+
CAPS,
|
|
213
|
+
BINDINGS,
|
|
214
|
+
dispatch,
|
|
215
|
+
{
|
|
216
|
+
mission_id: "mn_t",
|
|
217
|
+
transcript: () => {
|
|
218
|
+
throw new Error("transcript backend exploded");
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
assert.equal(result.status, "ok", "mission must complete despite sink throw");
|
|
223
|
+
assert.equal(result.steps_run, 1);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("transcript: an async sink that rejects also does NOT surface as unhandled rejection", async () => {
|
|
227
|
+
const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
|
|
228
|
+
const dispatch = makeDispatcher({ ros2_publish: { text: "ok" } }, log);
|
|
229
|
+
const result = await runMission(
|
|
230
|
+
{ steps: [{ id: "a", capability: "drive_base" }] },
|
|
231
|
+
CAPS,
|
|
232
|
+
BINDINGS,
|
|
233
|
+
dispatch,
|
|
234
|
+
{
|
|
235
|
+
mission_id: "mn_t",
|
|
236
|
+
transcript: async () => {
|
|
237
|
+
throw new Error("async sink rejection");
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
assert.equal(result.status, "ok");
|
|
242
|
+
// If the runner forwards the rejection up the event loop, node:test
|
|
243
|
+
// will mark this whole run as failed via process.on('unhandledRejection').
|
|
244
|
+
// A clean exit here is the contract.
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
248
|
+
// MissionRegistry + helpers
|
|
249
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
test("registry: register returns a fresh cancellation token; cancel flips it", () => {
|
|
252
|
+
const reg = new MissionRegistry();
|
|
253
|
+
const { entry, dispose } = reg.register("mn_1", { name: "test" });
|
|
254
|
+
assert.equal(entry.cancellation.cancelled, false);
|
|
255
|
+
const { found, alreadyCancelled } = reg.cancel("mn_1", "user request");
|
|
256
|
+
assert.equal(found, true);
|
|
257
|
+
assert.equal(alreadyCancelled, false);
|
|
258
|
+
assert.equal(entry.cancellation.cancelled, true);
|
|
259
|
+
assert.equal(entry.cancellation.reason, "user request");
|
|
260
|
+
dispose();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("registry: cancel on unknown id reports found:false (no throw)", () => {
|
|
264
|
+
const reg = new MissionRegistry();
|
|
265
|
+
const r = reg.cancel("nope");
|
|
266
|
+
assert.equal(r.found, false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("registry: cancel is idempotent — second call says alreadyCancelled", () => {
|
|
270
|
+
const reg = new MissionRegistry();
|
|
271
|
+
const { dispose } = reg.register("mn_dup");
|
|
272
|
+
const first = reg.cancel("mn_dup", "r1");
|
|
273
|
+
const second = reg.cancel("mn_dup", "r2");
|
|
274
|
+
assert.equal(first.alreadyCancelled, false);
|
|
275
|
+
assert.equal(second.alreadyCancelled, true);
|
|
276
|
+
// Second cancel may update the reason — that's a feature, not a bug
|
|
277
|
+
// (e.g. "user retry — they really mean it"). Just assert no throw.
|
|
278
|
+
dispose();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("registry: dispose removes the entry so subsequent cancel reports found:false", () => {
|
|
282
|
+
const reg = new MissionRegistry();
|
|
283
|
+
const { dispose } = reg.register("mn_disp");
|
|
284
|
+
dispose();
|
|
285
|
+
assert.equal(reg.cancel("mn_disp").found, false);
|
|
286
|
+
assert.equal(reg.has("mn_disp"), false);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("registry: list returns a defensive copy (mutation doesn't bleed back)", () => {
|
|
290
|
+
const reg = new MissionRegistry();
|
|
291
|
+
reg.register("mn_l1", { name: "one" });
|
|
292
|
+
reg.register("mn_l2", { name: "two" });
|
|
293
|
+
const snapshot = reg.list();
|
|
294
|
+
assert.equal(snapshot.length, 2);
|
|
295
|
+
snapshot[0].cancellation.cancelled = true;
|
|
296
|
+
assert.equal(reg.has("mn_l1"), true);
|
|
297
|
+
// The internal entry should NOT have been mutated by tampering with
|
|
298
|
+
// the snapshot's cancellation block.
|
|
299
|
+
const second = reg.list().find((e) => e.mission_id === "mn_l1");
|
|
300
|
+
assert.equal(second?.cancellation.cancelled, false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("registry: generateMissionId produces unique opaque ids with a stable prefix", () => {
|
|
304
|
+
const ids = new Set<string>();
|
|
305
|
+
for (let i = 0; i < 16; i++) ids.add(generateMissionId());
|
|
306
|
+
assert.equal(ids.size, 16, "16 calls must produce 16 distinct ids");
|
|
307
|
+
for (const id of ids) {
|
|
308
|
+
assert.match(id, /^mn_/, `id "${id}" must carry the mn_ prefix`);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("registry: missionTranscriptNamespace produces the canonical 'mission:<id>' shape", () => {
|
|
313
|
+
assert.equal(missionTranscriptNamespace("abc"), "mission:abc");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
317
|
+
// End-to-end glue: registry + runner + custom sink, no memory needed
|
|
318
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
test("end-to-end: register → run → cancel via registry → transcripts captured", async () => {
|
|
321
|
+
const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
|
|
322
|
+
const reg = new MissionRegistry();
|
|
323
|
+
const id = generateMissionId();
|
|
324
|
+
const { entry, dispose } = reg.register(id, { name: "demo" });
|
|
325
|
+
|
|
326
|
+
let count = 0;
|
|
327
|
+
const dispatch: MissionToolDispatcher = async (tool, args) => {
|
|
328
|
+
log.push({ tool, args });
|
|
329
|
+
count += 1;
|
|
330
|
+
// Cancel after the first step finishes — gives us a deterministic
|
|
331
|
+
// mid-run cancellation without races.
|
|
332
|
+
if (count === 1) reg.cancel(id, "operator stop");
|
|
333
|
+
return { text: "ok" };
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const transcriptEntries: MissionTranscriptEntry[] = [];
|
|
337
|
+
try {
|
|
338
|
+
const result = await runMission(
|
|
339
|
+
{
|
|
340
|
+
name: "demo",
|
|
341
|
+
steps: [
|
|
342
|
+
{ id: "s1", capability: "drive_base", inputs: { linear_x: 0.1 } },
|
|
343
|
+
{ id: "s2", capability: "drive_base", inputs: { linear_x: 0.2 } },
|
|
344
|
+
{ id: "s3", capability: "drive_base", inputs: { linear_x: 0.3 } },
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
CAPS,
|
|
348
|
+
BINDINGS,
|
|
349
|
+
dispatch,
|
|
350
|
+
{
|
|
351
|
+
mission_id: id,
|
|
352
|
+
cancellation: entry.cancellation,
|
|
353
|
+
transcript: (e) => {
|
|
354
|
+
transcriptEntries.push(e);
|
|
355
|
+
},
|
|
356
|
+
adapter: "e2e-test",
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
assert.equal(result.status, "cancelled");
|
|
360
|
+
assert.equal(result.mission_id, id);
|
|
361
|
+
assert.equal(result.cancellation_reason, "operator stop");
|
|
362
|
+
assert.equal(transcriptEntries.length, 3, "one transcript entry per step");
|
|
363
|
+
assert.deepEqual(
|
|
364
|
+
transcriptEntries.map((e) => e.result.status),
|
|
365
|
+
["ok", "cancelled", "cancelled"],
|
|
366
|
+
);
|
|
367
|
+
} finally {
|
|
368
|
+
dispose();
|
|
369
|
+
}
|
|
370
|
+
assert.equal(reg.has(id), false, "dispose must remove the entry");
|
|
371
|
+
});
|