agenticros 0.1.18 → 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 (96) 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/index.js +28 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/util/mcp-discovery.d.ts +57 -0
  16. package/dist/util/mcp-discovery.d.ts.map +1 -0
  17. package/dist/util/mcp-discovery.js +153 -0
  18. package/dist/util/mcp-discovery.js.map +1 -0
  19. package/dist/util/robot-config.d.ts +167 -0
  20. package/dist/util/robot-config.d.ts.map +1 -0
  21. package/dist/util/robot-config.js +375 -0
  22. package/dist/util/robot-config.js.map +1 -0
  23. package/dist/util/transport-shorthand.d.ts +66 -0
  24. package/dist/util/transport-shorthand.d.ts.map +1 -0
  25. package/dist/util/transport-shorthand.js +102 -0
  26. package/dist/util/transport-shorthand.js.map +1 -0
  27. package/package.json +2 -1
  28. package/runtime/BUNDLE.json +1 -1
  29. package/runtime/README.md +4 -0
  30. package/runtime/package.json +6 -1
  31. package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
  32. package/runtime/packages/agenticros/package.json +2 -1
  33. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
  34. package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
  35. package/runtime/packages/agenticros/src/service.ts +120 -2
  36. package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
  37. package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
  38. package/runtime/packages/agenticros/src/tools/index.ts +49 -9
  39. package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
  40. package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
  41. package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
  42. package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
  43. package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
  44. package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
  45. package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
  46. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
  47. package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
  48. package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
  49. package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
  50. package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
  51. package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
  52. package/runtime/packages/agenticros-claude-code/package.json +2 -1
  53. package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
  54. package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
  55. package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
  56. package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
  57. package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
  58. package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
  59. package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
  60. package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
  61. package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
  62. package/runtime/packages/agenticros-gemini/package.json +1 -0
  63. package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
  64. package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
  65. package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
  66. package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
  67. package/runtime/packages/core/package.json +1 -1
  68. package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
  69. package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
  70. package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
  71. package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
  72. package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
  73. package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
  74. package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
  75. package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
  76. package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
  77. package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
  78. package/runtime/packages/core/src/capabilities.ts +340 -0
  79. package/runtime/packages/core/src/config.ts +220 -38
  80. package/runtime/packages/core/src/discovery.ts +193 -0
  81. package/runtime/packages/core/src/find-robots-for.ts +164 -0
  82. package/runtime/packages/core/src/index.ts +67 -1
  83. package/runtime/packages/core/src/mission-registry.ts +142 -0
  84. package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
  85. package/runtime/packages/core/src/mission.ts +574 -0
  86. package/runtime/packages/core/src/planner/index.ts +564 -0
  87. package/runtime/packages/core/src/robots.ts +282 -0
  88. package/runtime/packages/core/src/topic-utils.ts +47 -9
  89. package/runtime/packages/core/src/transport-pool.ts +171 -0
  90. package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
  91. package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
  92. package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
  93. package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
  94. package/runtime/scripts/refresh-skill-deps.mjs +381 -0
  95. package/runtime/scripts/setup_gateway_plugin.sh +32 -5
  96. package/runtime/scripts/sync-skill-tools.mjs +67 -10
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Unit tests for the memory-backed mission transcript sink.
3
+ *
4
+ * Pins down the cross-adapter resume story: a mission run by agent A
5
+ * leaves a JSON trail under namespace `mission:<id>` that agent B can
6
+ * read via memory_recall. The sink itself is a thin shim, so we test:
7
+ *
8
+ * - One sink call → exactly one memory.remember() call.
9
+ * - Namespace is `mission:<missionId>` (canonical via
10
+ * missionTranscriptNamespace).
11
+ * - content is JSON-parseable and contains every transcript field
12
+ * the LLM cares about (mission_id, step.{id, capability, status,
13
+ * inputs, outputs}).
14
+ * - tags include `mission_transcript`, `step:<status>`, and
15
+ * `capability:<id>` (used for future recall filters).
16
+ * - path is sortable (zero-padded step_index) so a recall sorted by
17
+ * path returns the steps in execution order.
18
+ * - A throwing memory.remember() does NOT propagate (best-effort
19
+ * contract — transcript loss must never abort a mission).
20
+ * - An async-rejecting memory.remember() doesn't propagate either.
21
+ */
22
+ import { test } from "node:test";
23
+ import assert from "node:assert/strict";
24
+
25
+ import { createMemoryTranscriptSink } from "../mission-transcript-sink.js";
26
+ import { missionTranscriptNamespace } from "../mission-registry.js";
27
+ import type { MissionTranscriptEntry } from "../mission.js";
28
+ import type { MemoryProvider, MemoryRecord } from "../memory/index.js";
29
+
30
+ interface RememberCall {
31
+ namespace: string;
32
+ content: string;
33
+ tags?: string[];
34
+ path?: string;
35
+ }
36
+
37
+ function makeFakeMemory(opts?: {
38
+ rememberImpl?: (call: RememberCall) => void | Promise<void>;
39
+ }): { memory: MemoryProvider; calls: RememberCall[] } {
40
+ const calls: RememberCall[] = [];
41
+ const memory: MemoryProvider = {
42
+ backend: "local",
43
+ async remember(input) {
44
+ const call: RememberCall = {
45
+ namespace: input.namespace ?? "",
46
+ content: input.content,
47
+ tags: input.tags,
48
+ path: input.path,
49
+ };
50
+ calls.push(call);
51
+ if (opts?.rememberImpl) await opts.rememberImpl(call);
52
+ return {
53
+ id: `rec_${calls.length}`,
54
+ content: input.content,
55
+ namespace: call.namespace,
56
+ createdAt: Date.now(),
57
+ } satisfies MemoryRecord;
58
+ },
59
+ async recall() {
60
+ return [];
61
+ },
62
+ async forget() {
63
+ return { removed: 0 };
64
+ },
65
+ async status() {
66
+ return { enabled: true, backend: "local", namespace: "", recordCount: 0, lastWriteAt: null };
67
+ },
68
+ async recent() {
69
+ return [];
70
+ },
71
+ };
72
+ return { memory, calls };
73
+ }
74
+
75
+ function makeEntry(opts: Partial<MissionTranscriptEntry> & {
76
+ step: { id: string; capability: string; status: "ok" | "error" | "skipped" | "cancelled"; outputs?: Record<string, unknown> };
77
+ step_index: number;
78
+ }): MissionTranscriptEntry {
79
+ return {
80
+ mission_id: opts.mission_id ?? "mn_default",
81
+ mission_name: opts.mission_name,
82
+ adapter: opts.adapter,
83
+ started_at: opts.started_at ?? 1700000000000,
84
+ robot_id: opts.robot_id ?? "",
85
+ step_index: opts.step_index,
86
+ step_total: opts.step_total ?? 3,
87
+ result: {
88
+ id: opts.step.id,
89
+ capability: opts.step.capability,
90
+ status: opts.step.status,
91
+ inputs: { foo: "bar" },
92
+ outputs: opts.step.outputs,
93
+ duration_ms: 42,
94
+ },
95
+ };
96
+ }
97
+
98
+ test("sink: writes one memory.remember per entry, under mission:<id> namespace", async () => {
99
+ const { memory, calls } = makeFakeMemory();
100
+ const sink = createMemoryTranscriptSink(memory, "mn_alpha");
101
+ await sink(makeEntry({ step: { id: "s1", capability: "drive_base", status: "ok" }, step_index: 0 }));
102
+ assert.equal(calls.length, 1);
103
+ assert.equal(calls[0].namespace, missionTranscriptNamespace("mn_alpha"));
104
+ });
105
+
106
+ test("sink: content is JSON-parseable and carries the full step snapshot", async () => {
107
+ const { memory, calls } = makeFakeMemory();
108
+ const sink = createMemoryTranscriptSink(memory, "mn_beta");
109
+ await sink(
110
+ makeEntry({
111
+ mission_id: "mn_beta",
112
+ mission_name: "demo",
113
+ adapter: "claude-code",
114
+ step: {
115
+ id: "find",
116
+ capability: "find_object",
117
+ status: "ok",
118
+ outputs: { found: true, horizontal_offset: 0.12 },
119
+ },
120
+ step_index: 1,
121
+ step_total: 3,
122
+ }),
123
+ );
124
+ const payload = JSON.parse(calls[0].content) as {
125
+ mission_id: string;
126
+ mission_name: string;
127
+ adapter: string;
128
+ step_index: number;
129
+ step_total: number;
130
+ step: {
131
+ id: string;
132
+ capability: string;
133
+ status: string;
134
+ inputs: Record<string, unknown>;
135
+ outputs: Record<string, unknown>;
136
+ };
137
+ };
138
+ assert.equal(payload.mission_id, "mn_beta");
139
+ assert.equal(payload.mission_name, "demo");
140
+ assert.equal(payload.adapter, "claude-code");
141
+ assert.equal(payload.step_index, 1);
142
+ assert.equal(payload.step_total, 3);
143
+ assert.equal(payload.step.id, "find");
144
+ assert.equal(payload.step.capability, "find_object");
145
+ assert.equal(payload.step.status, "ok");
146
+ assert.deepEqual(payload.step.outputs, { found: true, horizontal_offset: 0.12 });
147
+ });
148
+
149
+ test("sink: tags include mission_transcript + step:<status> + capability:<id>", async () => {
150
+ const { memory, calls } = makeFakeMemory();
151
+ const sink = createMemoryTranscriptSink(memory, "mn_g");
152
+ await sink(makeEntry({ step: { id: "drive", capability: "drive_base", status: "ok" }, step_index: 0 }));
153
+ await sink(makeEntry({ step: { id: "bad", capability: "find_object", status: "error" }, step_index: 1 }));
154
+ assert.deepEqual(new Set(calls[0].tags), new Set(["mission_transcript", "step:ok", "capability:drive_base"]));
155
+ assert.deepEqual(new Set(calls[1].tags), new Set(["mission_transcript", "step:error", "capability:find_object"]));
156
+ });
157
+
158
+ test("sink: path uses zero-padded step_index so recall sort-by-path stays in execution order", async () => {
159
+ const { memory, calls } = makeFakeMemory();
160
+ const sink = createMemoryTranscriptSink(memory, "mn_p");
161
+ await sink(makeEntry({ step: { id: "s1", capability: "drive_base", status: "ok" }, step_index: 0 }));
162
+ await sink(makeEntry({ step: { id: "s11", capability: "drive_base", status: "ok" }, step_index: 10 }));
163
+ // Lexicographic sort of the two paths must put step 0 before step 10.
164
+ assert.equal(calls[0].path, "mn_p/000-s1");
165
+ assert.equal(calls[1].path, "mn_p/010-s11");
166
+ const sorted = [...calls].map((c) => c.path!).sort();
167
+ assert.deepEqual(sorted, ["mn_p/000-s1", "mn_p/010-s11"]);
168
+ });
169
+
170
+ test("sink: synchronous throws inside memory.remember don't propagate (best-effort)", async () => {
171
+ const { memory } = makeFakeMemory({
172
+ rememberImpl: () => {
173
+ throw new Error("boom");
174
+ },
175
+ });
176
+ const sink = createMemoryTranscriptSink(memory, "mn_z");
177
+ // Must not throw.
178
+ await sink(makeEntry({ step: { id: "s1", capability: "drive_base", status: "ok" }, step_index: 0 }));
179
+ });
180
+
181
+ test("sink: async rejections inside memory.remember don't propagate", async () => {
182
+ const { memory } = makeFakeMemory({
183
+ rememberImpl: async () => {
184
+ throw new Error("async boom");
185
+ },
186
+ });
187
+ const sink = createMemoryTranscriptSink(memory, "mn_zz");
188
+ await sink(makeEntry({ step: { id: "s1", capability: "drive_base", status: "ok" }, step_index: 0 }));
189
+ });
@@ -0,0 +1,501 @@
1
+ /**
2
+ * Unit tests for the Phase 1.c mission runner.
3
+ *
4
+ * These tests don't need a live transport — they exercise the runner with
5
+ * a fake dispatcher that records the tool calls and returns canned
6
+ * responses. The goal is to pin down the behaviour the LLM relies on:
7
+ *
8
+ * - Steps execute in declaration order.
9
+ * - `{{stepId.outputs.field}}` resolves from prior step outputs.
10
+ * - Single-template strings preserve type (numeric outputs stay numeric).
11
+ * - Unknown capabilities fail with a clear, actionable message.
12
+ * - Capabilities without a binding fail with a clear, actionable message.
13
+ * - `on_fail: "continue"` skips ahead instead of aborting.
14
+ * - `on_fail: "stop"` (default) aborts and marks remaining steps "skipped".
15
+ * - Tool exceptions are caught and converted to step errors.
16
+ * - Tool isError flag propagates to step status.
17
+ * - JSON outputs are auto-parsed from the text payload when the binding
18
+ * doesn't supply a parseOutputs hook.
19
+ */
20
+ import { test } from "node:test";
21
+ import assert from "node:assert/strict";
22
+
23
+ import {
24
+ runMission,
25
+ type CapabilityToolBindings,
26
+ type Mission,
27
+ type MissionToolDispatcher,
28
+ } from "../mission.js";
29
+ import type { Capability } from "../capabilities.js";
30
+
31
+ const CAPS: Capability[] = [
32
+ {
33
+ id: "drive_base",
34
+ verb: "drive",
35
+ description: "drive",
36
+ source: { kind: "builtin" },
37
+ },
38
+ {
39
+ id: "take_snapshot",
40
+ verb: "see",
41
+ description: "see",
42
+ source: { kind: "builtin" },
43
+ },
44
+ {
45
+ id: "find_object",
46
+ verb: "find",
47
+ description: "find",
48
+ source: { kind: "skill", skillId: "find", package: "agenticros-skill-find" },
49
+ },
50
+ {
51
+ id: "no_binding_cap",
52
+ verb: "demo",
53
+ description: "Registered but has no binding entry.",
54
+ source: { kind: "skill", skillId: "demo", package: "agenticros-skill-demo" },
55
+ },
56
+ ];
57
+
58
+ function makeDispatcher(
59
+ responses: Record<string, { text: string; outputs?: Record<string, unknown>; isError?: boolean } | Error>,
60
+ log: Array<{ tool: string; args: Record<string, unknown> }>,
61
+ ): MissionToolDispatcher {
62
+ return async (tool, args) => {
63
+ log.push({ tool, args });
64
+ const r = responses[tool];
65
+ if (!r) return { text: `(no response stub for ${tool})` };
66
+ if (r instanceof Error) throw r;
67
+ return r;
68
+ };
69
+ }
70
+
71
+ const BINDINGS: CapabilityToolBindings = {
72
+ drive_base: {
73
+ tool: "ros2_publish",
74
+ buildArgs: (inputs) => ({
75
+ topic: "cmd_vel",
76
+ msg_type: "geometry_msgs/Twist",
77
+ msg: {
78
+ linear: { x: inputs.linear_x ?? 0, y: 0, z: 0 },
79
+ angular: { x: 0, y: 0, z: inputs.angular_z ?? 0 },
80
+ },
81
+ }),
82
+ },
83
+ take_snapshot: {
84
+ tool: "ros2_camera_snapshot",
85
+ buildArgs: () => ({}),
86
+ },
87
+ find_object: {
88
+ tool: "ros2_find_object",
89
+ buildArgs: (inputs) => ({ target: inputs.target, timeout_s: inputs.timeout_s ?? 20 }),
90
+ },
91
+ };
92
+
93
+ test("mission: empty mission returns ok with no steps", async () => {
94
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
95
+ const dispatch = makeDispatcher({}, log);
96
+ const result = await runMission({ steps: [] }, CAPS, BINDINGS, dispatch);
97
+ assert.equal(result.status, "ok");
98
+ assert.equal(result.steps_total, 0);
99
+ assert.equal(result.steps_run, 0);
100
+ assert.equal(log.length, 0);
101
+ });
102
+
103
+ test("mission: single-step success dispatches the right tool with the right args", async () => {
104
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
105
+ const dispatch = makeDispatcher(
106
+ { ros2_publish: { text: "Published." } },
107
+ log,
108
+ );
109
+ const mission: Mission = {
110
+ name: "drive forward",
111
+ steps: [{ id: "go", capability: "drive_base", inputs: { linear_x: 0.3 } }],
112
+ };
113
+ const result = await runMission(mission, CAPS, BINDINGS, dispatch);
114
+ assert.equal(result.status, "ok");
115
+ assert.equal(result.steps_run, 1);
116
+ assert.equal(log.length, 1);
117
+ assert.equal(log[0].tool, "ros2_publish");
118
+ assert.deepEqual(log[0].args, {
119
+ topic: "cmd_vel",
120
+ msg_type: "geometry_msgs/Twist",
121
+ msg: { linear: { x: 0.3, y: 0, z: 0 }, angular: { x: 0, y: 0, z: 0 } },
122
+ });
123
+ });
124
+
125
+ test("mission: template substitution preserves numeric type for whole-string refs", async () => {
126
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
127
+ const dispatch = makeDispatcher(
128
+ {
129
+ ros2_find_object: {
130
+ text: '{"found": true, "horizontal_offset": 0.42}',
131
+ },
132
+ ros2_publish: { text: "Published." },
133
+ },
134
+ log,
135
+ );
136
+ const mission: Mission = {
137
+ name: "find chair then nudge",
138
+ steps: [
139
+ { id: "find", capability: "find_object", inputs: { target: "chair" } },
140
+ {
141
+ id: "nudge",
142
+ capability: "drive_base",
143
+ inputs: { linear_x: 0.2, angular_z: "{{find.outputs.horizontal_offset}}" },
144
+ },
145
+ ],
146
+ };
147
+ const result = await runMission(mission, CAPS, BINDINGS, dispatch);
148
+ assert.equal(result.status, "ok", result.summary);
149
+ assert.equal(log.length, 2);
150
+ // The crucial assertion: angular_z is a NUMBER (0.42), not the string "0.42".
151
+ const nudgeArgs = log[1].args;
152
+ const msg = nudgeArgs.msg as { angular: { z: unknown } };
153
+ assert.equal(msg.angular.z, 0.42);
154
+ assert.equal(typeof msg.angular.z, "number");
155
+ });
156
+
157
+ test("mission: template substitution works inside string interpolation", async () => {
158
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
159
+ // Add a capability whose buildArgs uses a string input.
160
+ const bindings: CapabilityToolBindings = {
161
+ ...BINDINGS,
162
+ take_snapshot: {
163
+ tool: "ros2_camera_snapshot",
164
+ buildArgs: (inputs) => ({ label: inputs.label }),
165
+ },
166
+ find_object: {
167
+ tool: "ros2_find_object",
168
+ buildArgs: (inputs) => ({ target: inputs.target }),
169
+ },
170
+ };
171
+ const dispatch = makeDispatcher(
172
+ {
173
+ ros2_find_object: { text: '{"found": true, "object": "chair"}' },
174
+ ros2_camera_snapshot: { text: "Snapshot taken." },
175
+ },
176
+ log,
177
+ );
178
+ const mission: Mission = {
179
+ steps: [
180
+ { id: "find", capability: "find_object", inputs: { target: "chair" } },
181
+ {
182
+ id: "snap",
183
+ capability: "take_snapshot",
184
+ inputs: { label: "After finding a {{find.outputs.object}}." },
185
+ },
186
+ ],
187
+ };
188
+ const result = await runMission(mission, CAPS, bindings, dispatch);
189
+ assert.equal(result.status, "ok");
190
+ assert.equal(log[1].args.label, "After finding a chair.");
191
+ });
192
+
193
+ test("mission: unknown capability fails with actionable message", async () => {
194
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
195
+ const dispatch = makeDispatcher({}, log);
196
+ const result = await runMission(
197
+ { steps: [{ id: "x", capability: "doesnt_exist" }] },
198
+ CAPS,
199
+ BINDINGS,
200
+ dispatch,
201
+ );
202
+ assert.equal(result.status, "error");
203
+ assert.equal(result.steps[0].status, "error");
204
+ assert.ok(result.steps[0].error?.includes("ros2_list_capabilities"));
205
+ assert.equal(log.length, 0);
206
+ });
207
+
208
+ test("mission: registered capability without a binding fails clearly", async () => {
209
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
210
+ const dispatch = makeDispatcher({}, log);
211
+ const result = await runMission(
212
+ { steps: [{ id: "x", capability: "no_binding_cap" }] },
213
+ CAPS,
214
+ BINDINGS,
215
+ dispatch,
216
+ );
217
+ assert.equal(result.status, "error");
218
+ assert.ok(result.steps[0].error?.includes("no mission-runner tool binding"));
219
+ assert.equal(log.length, 0);
220
+ });
221
+
222
+ test("mission: on_fail=stop (default) aborts and skips remaining steps", async () => {
223
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
224
+ const dispatch = makeDispatcher(
225
+ {
226
+ ros2_find_object: { text: "Object not found.", isError: true },
227
+ ros2_publish: { text: "Published." },
228
+ },
229
+ log,
230
+ );
231
+ const mission: Mission = {
232
+ steps: [
233
+ { id: "find", capability: "find_object", inputs: { target: "chair" } },
234
+ { id: "go", capability: "drive_base", inputs: { linear_x: 0.2 } },
235
+ ],
236
+ };
237
+ const result = await runMission(mission, CAPS, BINDINGS, dispatch);
238
+ assert.equal(result.status, "error");
239
+ assert.equal(result.steps[0].status, "error");
240
+ assert.equal(result.steps[1].status, "skipped");
241
+ assert.equal(result.steps_run, 1, "only one step actually ran");
242
+ assert.equal(log.length, 1, "drive should never have been dispatched");
243
+ });
244
+
245
+ test("mission: on_fail=continue runs subsequent steps even after failure", async () => {
246
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
247
+ const dispatch = makeDispatcher(
248
+ {
249
+ ros2_find_object: { text: "Object not found.", isError: true },
250
+ ros2_publish: { text: "Published." },
251
+ },
252
+ log,
253
+ );
254
+ const mission: Mission = {
255
+ steps: [
256
+ { id: "find", capability: "find_object", inputs: { target: "chair" }, on_fail: "continue" },
257
+ { id: "go", capability: "drive_base", inputs: { linear_x: 0.2 } },
258
+ ],
259
+ };
260
+ const result = await runMission(mission, CAPS, BINDINGS, dispatch);
261
+ assert.equal(result.status, "error", "overall status remains error if any step failed");
262
+ assert.equal(result.steps[0].status, "error");
263
+ assert.equal(result.steps[1].status, "ok");
264
+ assert.equal(result.steps_run, 2);
265
+ assert.equal(log.length, 2);
266
+ });
267
+
268
+ test("mission: tool exceptions are caught and surfaced as step errors", async () => {
269
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
270
+ const dispatch = makeDispatcher(
271
+ { ros2_publish: new Error("transport disconnected") },
272
+ log,
273
+ );
274
+ const result = await runMission(
275
+ {
276
+ steps: [{ id: "x", capability: "drive_base", inputs: { linear_x: 0.1 } }],
277
+ },
278
+ CAPS,
279
+ BINDINGS,
280
+ dispatch,
281
+ );
282
+ assert.equal(result.status, "error");
283
+ assert.equal(result.steps[0].error, "transport disconnected");
284
+ });
285
+
286
+ test("mission: outputs are auto-parsed from JSON text when binding has no parseOutputs", async () => {
287
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
288
+ const dispatch = makeDispatcher(
289
+ {
290
+ ros2_find_object: { text: '{"found": true, "horizontal_offset": 0.1}' },
291
+ },
292
+ log,
293
+ );
294
+ const result = await runMission(
295
+ { steps: [{ id: "find", capability: "find_object", inputs: { target: "chair" } }] },
296
+ CAPS,
297
+ BINDINGS,
298
+ dispatch,
299
+ );
300
+ assert.equal(result.status, "ok");
301
+ assert.deepEqual(result.steps[0].outputs, { found: true, horizontal_offset: 0.1 });
302
+ });
303
+
304
+ test("mission: explicit outputs from dispatcher take precedence over auto-parse", async () => {
305
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
306
+ const dispatch = makeDispatcher(
307
+ {
308
+ ros2_find_object: {
309
+ text: '{"found": true, "horizontal_offset": 0.1}',
310
+ outputs: { found: true, horizontal_offset: 0.9, source: "explicit" },
311
+ },
312
+ },
313
+ log,
314
+ );
315
+ const result = await runMission(
316
+ { steps: [{ id: "find", capability: "find_object", inputs: { target: "chair" } }] },
317
+ CAPS,
318
+ BINDINGS,
319
+ dispatch,
320
+ );
321
+ assert.equal(result.steps[0].outputs?.source, "explicit");
322
+ assert.equal(result.steps[0].outputs?.horizontal_offset, 0.9);
323
+ });
324
+
325
+ test("mission: summary line reports success counts when all green", async () => {
326
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
327
+ const dispatch = makeDispatcher({ ros2_publish: { text: "ok" } }, log);
328
+ const result = await runMission(
329
+ {
330
+ name: "patrol",
331
+ steps: [
332
+ { id: "a", capability: "drive_base", inputs: { linear_x: 0.1 } },
333
+ { id: "b", capability: "drive_base", inputs: { linear_x: 0.0 } },
334
+ ],
335
+ },
336
+ CAPS,
337
+ BINDINGS,
338
+ dispatch,
339
+ );
340
+ assert.equal(result.status, "ok");
341
+ assert.ok(result.summary.includes("patrol"));
342
+ assert.ok(result.summary.includes("2/2"));
343
+ });
344
+
345
+ test("mission: summary line names the failed step when any step errored", async () => {
346
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
347
+ const dispatch = makeDispatcher(
348
+ { ros2_publish: { text: "boom", isError: true } },
349
+ log,
350
+ );
351
+ const result = await runMission(
352
+ {
353
+ name: "wave-and-go",
354
+ steps: [{ id: "go", capability: "drive_base", inputs: { linear_x: 0.1 } }],
355
+ },
356
+ CAPS,
357
+ BINDINGS,
358
+ dispatch,
359
+ );
360
+ assert.equal(result.status, "error");
361
+ assert.ok(result.summary.includes('"go"'));
362
+ });
363
+
364
+ // --- Phase 1.d: multi-robot routing tests ---
365
+ //
366
+ // These pin down the robot_id injection contract that adapters depend on.
367
+ // The runner is transport-agnostic so it doesn't validate the id itself —
368
+ // it just makes sure the right id ends up in toolArgs.robot_id for every
369
+ // dispatched call, following the documented precedence:
370
+ // 1. binding.buildArgs set it (left alone)
371
+ // 2. step.inputs.robot_id (per-step override)
372
+ // 3. mission.robot_id (mission-level default)
373
+ // 4. none (no robot_id key on toolArgs)
374
+ //
375
+ // Empty/whitespace strings at levels 2-3 are treated as "not set" so
376
+ // agents can leave them empty without accidentally pinning to "".
377
+
378
+ test("mission: mission.robot_id is injected into every dispatched tool's args", async () => {
379
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
380
+ const dispatch = makeDispatcher({ ros2_publish: { text: "ok" } }, log);
381
+ const result = await runMission(
382
+ {
383
+ name: "drive-alpha",
384
+ robot_id: "alpha",
385
+ steps: [
386
+ { id: "a", capability: "drive_base", inputs: { linear_x: 0.1 } },
387
+ { id: "b", capability: "drive_base", inputs: { linear_x: 0.2 } },
388
+ ],
389
+ },
390
+ CAPS,
391
+ BINDINGS,
392
+ dispatch,
393
+ );
394
+ assert.equal(result.status, "ok");
395
+ assert.equal(log.length, 2);
396
+ for (const entry of log) {
397
+ assert.equal(entry.args.robot_id, "alpha", `every dispatched tool call must carry robot_id=alpha (got ${JSON.stringify(entry.args.robot_id)})`);
398
+ }
399
+ assert.equal(result.robot_id, "alpha", "result should surface the mission-level robot_id");
400
+ });
401
+
402
+ test("mission: per-step inputs.robot_id overrides mission.robot_id for that step", async () => {
403
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
404
+ const dispatch = makeDispatcher({ ros2_publish: { text: "ok" } }, log);
405
+ await runMission(
406
+ {
407
+ robot_id: "alpha",
408
+ steps: [
409
+ { id: "a", capability: "drive_base", inputs: { linear_x: 0.1 } },
410
+ { id: "b", capability: "drive_base", inputs: { linear_x: 0.2, robot_id: "beta" } },
411
+ { id: "c", capability: "drive_base", inputs: { linear_x: 0.3 } },
412
+ ],
413
+ },
414
+ CAPS,
415
+ BINDINGS,
416
+ dispatch,
417
+ );
418
+ assert.equal(log[0].args.robot_id, "alpha", "step a falls back to mission default");
419
+ assert.equal(log[1].args.robot_id, "beta", "step b's inputs.robot_id wins");
420
+ assert.equal(log[2].args.robot_id, "alpha", "step c falls back to mission default again");
421
+ });
422
+
423
+ test("mission: no robot_id at any level leaves toolArgs untouched", async () => {
424
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
425
+ const dispatch = makeDispatcher({ ros2_publish: { text: "ok" } }, log);
426
+ await runMission(
427
+ { steps: [{ id: "a", capability: "drive_base", inputs: { linear_x: 0.1 } }] },
428
+ CAPS,
429
+ BINDINGS,
430
+ dispatch,
431
+ );
432
+ assert.equal(log.length, 1);
433
+ assert.ok(!("robot_id" in log[0].args), "robot_id must not be added when no level set it");
434
+ });
435
+
436
+ test("mission: empty / whitespace robot_id strings are treated as unset", async () => {
437
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
438
+ const dispatch = makeDispatcher({ ros2_publish: { text: "ok" } }, log);
439
+ await runMission(
440
+ {
441
+ robot_id: " ",
442
+ steps: [
443
+ { id: "a", capability: "drive_base", inputs: { linear_x: 0.1, robot_id: "" } },
444
+ { id: "b", capability: "drive_base", inputs: { linear_x: 0.2 } },
445
+ ],
446
+ },
447
+ CAPS,
448
+ BINDINGS,
449
+ dispatch,
450
+ );
451
+ assert.ok(!("robot_id" in log[0].args), "step-level empty string falls through to mission, mission's whitespace = unset");
452
+ assert.ok(!("robot_id" in log[1].args), "no override + whitespace mission = unset");
453
+ });
454
+
455
+ test("mission: binding-supplied robot_id wins over both inputs and mission", async () => {
456
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
457
+ // A binding that hard-codes robot_id (a rare but legal pattern — e.g. a
458
+ // capability that targets a fixed companion robot regardless of the
459
+ // mission default).
460
+ const bindings: CapabilityToolBindings = {
461
+ ...BINDINGS,
462
+ drive_base: {
463
+ tool: "ros2_publish",
464
+ buildArgs: (inputs) => ({
465
+ topic: "cmd_vel",
466
+ robot_id: "binding-pinned",
467
+ msg: { linear: { x: inputs.linear_x ?? 0 } },
468
+ }),
469
+ },
470
+ };
471
+ const dispatch = makeDispatcher({ ros2_publish: { text: "ok" } }, log);
472
+ await runMission(
473
+ {
474
+ robot_id: "alpha",
475
+ steps: [{ id: "a", capability: "drive_base", inputs: { linear_x: 0.1, robot_id: "beta" } }],
476
+ },
477
+ CAPS,
478
+ bindings,
479
+ dispatch,
480
+ );
481
+ assert.equal(log[0].args.robot_id, "binding-pinned", "binding's robot_id must not be overwritten");
482
+ });
483
+
484
+ test("mission: result.robot_id reflects the mission-level default (empty when unset)", async () => {
485
+ const log: Array<{ tool: string; args: Record<string, unknown> }> = [];
486
+ const dispatch = makeDispatcher({ ros2_publish: { text: "ok" } }, log);
487
+ const a = await runMission(
488
+ { robot_id: "alpha", steps: [{ id: "x", capability: "drive_base", inputs: { linear_x: 0.1 } }] },
489
+ CAPS,
490
+ BINDINGS,
491
+ dispatch,
492
+ );
493
+ const b = await runMission(
494
+ { steps: [{ id: "x", capability: "drive_base", inputs: { linear_x: 0.1 } }] },
495
+ CAPS,
496
+ BINDINGS,
497
+ dispatch,
498
+ );
499
+ assert.equal(a.robot_id, "alpha");
500
+ assert.equal(b.robot_id, "");
501
+ });