agenticros 0.1.18 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +125 -2
- package/dist/commands/skills.js.map +1 -1
- package/dist/index.js +29 -1
- package/dist/index.js.map +1 -1
- package/dist/menu.js +22 -4
- package/dist/menu.js.map +1 -1
- package/dist/util/marketplace.d.ts +57 -0
- package/dist/util/marketplace.d.ts.map +1 -0
- package/dist/util/marketplace.js +144 -0
- package/dist/util/marketplace.js.map +1 -0
- 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/skills.d.ts +6 -6
- package/dist/util/skills.d.ts.map +1 -1
- package/dist/util/skills.js +14 -9
- package/dist/util/skills.js.map +1 -1
- 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 +12 -10
- package/runtime/BUNDLE.json +1 -1
- package/runtime/README.md +62 -1
- package/runtime/package.json +6 -1
- package/runtime/packages/agenticros/openclaw.plugin.json +7 -1
- 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/config-page.ts +1 -1
- 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 +109 -30
- 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/README.md +52 -9
- package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.d.ts +2 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.d.ts.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.js +773 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/capabilities-mcp.test.js.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.d.ts +2 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.d.ts.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.js +97 -0
- package/runtime/packages/agenticros-claude-code/dist/__tests__/follow-me-registry.test.js.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts +2 -2
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js +5 -5
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.d.ts +109 -0
- package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.d.ts.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.js +392 -0
- package/runtime/packages/agenticros-claude-code/dist/follow-me/depth-loop.js.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.d.ts +8 -3
- package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.js +30 -12
- package/runtime/packages/agenticros-claude-code/dist/follow-me/loop.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/index.js +5 -5
- package/runtime/packages/agenticros-claude-code/dist/index.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/mission-registry.d.ts +16 -0
- package/runtime/packages/agenticros-claude-code/dist/mission-registry.d.ts.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/mission-registry.js +19 -0
- package/runtime/packages/agenticros-claude-code/dist/mission-registry.js.map +1 -0
- package/runtime/packages/agenticros-claude-code/dist/tools.d.ts +9 -0
- package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/tools.js +700 -54
- package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/transport.d.ts +28 -8
- package/runtime/packages/agenticros-claude-code/dist/transport.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/transport.js +37 -34
- package/runtime/packages/agenticros-claude-code/dist/transport.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/package.json +2 -1
- package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +965 -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/LICENSE +192 -0
- package/runtime/packages/core/README.md +88 -0
- package/runtime/packages/core/package.json +36 -3
- package/runtime/packages/core/src/__tests__/capabilities.test.ts +266 -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 +314 -0
- package/runtime/packages/core/src/config.ts +221 -39
- 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/configure_agenticros.sh +0 -0
- package/runtime/scripts/configure_for_sim.sh +0 -0
- package/runtime/scripts/fix-openclaw-control-ui-path.sh +0 -0
- package/runtime/scripts/install_cli.sh +0 -0
- package/runtime/scripts/install_rosbridge_from_source.sh +0 -0
- package/runtime/scripts/onboard_robot.sh +0 -0
- package/runtime/scripts/pack-runtime.mjs +0 -0
- package/runtime/scripts/refresh-skill-deps.mjs +381 -0
- package/runtime/scripts/run_demo_native.sh +0 -0
- package/runtime/scripts/run_nemoclaw_host_stack.sh +0 -0
- package/runtime/scripts/run_robot_rosbridge.sh +0 -0
- package/runtime/scripts/setup_gateway_plugin.sh +32 -5
- package/runtime/scripts/setup_robot.sh +0 -0
- package/runtime/scripts/setup_workspace.sh +0 -0
- package/runtime/scripts/sim/run_sim.sh +0 -0
- package/runtime/scripts/smoke_test_nemoclaw.sh +0 -0
- package/runtime/scripts/start_demo.sh +0 -0
- package/runtime/scripts/sync-skill-tools.mjs +69 -12
- package/runtime/scripts/use-openclaw-2026.2.26.sh +0 -0
- package/runtime/scripts/use-openclaw-2026.3.11.sh +0 -0
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for ros2_list_capabilities via the MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Spawns the AgenticROS MCP server stdio entrypoint (exactly the binary
|
|
5
|
+
* Claude Code launches), drives the JSON-RPC handshake, and asserts:
|
|
6
|
+
*
|
|
7
|
+
* 1. tools/list contains ros2_list_capabilities.
|
|
8
|
+
* 2. tools/call returns a well-formed payload with the expected shape.
|
|
9
|
+
* 3. The 6 intrinsic verbs are all present.
|
|
10
|
+
* 4. A skill-declared capability we plant via skillPaths shows up.
|
|
11
|
+
* 5. The call succeeds with NO ROS transport running — proving the
|
|
12
|
+
* no-transport short-circuit in index.ts and tools.ts is wired.
|
|
13
|
+
*
|
|
14
|
+
* This is the regression net for the bug we hit on 2026-06-10 where the
|
|
15
|
+
* outer `ensureConnected()` gate would block transport-free tools
|
|
16
|
+
* unless NO_TRANSPORT_TOOL_NAMES included them. If somebody removes
|
|
17
|
+
* `ros2_list_capabilities` from that set, this test will hang on
|
|
18
|
+
* Zenoh connect and time out clearly.
|
|
19
|
+
*/
|
|
20
|
+
import { test } from "node:test";
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
23
|
+
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { dirname } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
const __dirname = dirname(__filename);
|
|
31
|
+
// dist/__tests__/foo.test.js -> dist/__tests__ -> dist -> packages/agenticros-claude-code
|
|
32
|
+
const PKG_ROOT = path.join(__dirname, "..", "..");
|
|
33
|
+
const MCP_ENTRY = path.join(PKG_ROOT, "dist", "index.js");
|
|
34
|
+
|
|
35
|
+
interface JsonRpcResponse {
|
|
36
|
+
jsonrpc: "2.0";
|
|
37
|
+
id?: number;
|
|
38
|
+
result?: Record<string, unknown>;
|
|
39
|
+
error?: { code: number; message: string };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Minimal JSON-RPC client over a child process's stdio. Implements just
|
|
44
|
+
* enough to drive an MCP `initialize` handshake plus tools/list and
|
|
45
|
+
* tools/call requests.
|
|
46
|
+
*/
|
|
47
|
+
class StdioRpcClient {
|
|
48
|
+
private buffer = "";
|
|
49
|
+
private nextId = 1;
|
|
50
|
+
private pending = new Map<number, (msg: JsonRpcResponse) => void>();
|
|
51
|
+
|
|
52
|
+
constructor(private readonly proc: ChildProcessWithoutNullStreams) {
|
|
53
|
+
proc.stdout.setEncoding("utf8");
|
|
54
|
+
proc.stdout.on("data", (chunk: string) => this.onChunk(chunk));
|
|
55
|
+
proc.stderr.setEncoding("utf8");
|
|
56
|
+
// Drain stderr silently — the server writes a banner + diagnostic logs.
|
|
57
|
+
proc.stderr.on("data", () => {});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private onChunk(chunk: string): void {
|
|
61
|
+
this.buffer += chunk;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const nl = this.buffer.indexOf("\n");
|
|
64
|
+
if (nl < 0) break;
|
|
65
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
66
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
67
|
+
if (!line) continue;
|
|
68
|
+
let msg: JsonRpcResponse;
|
|
69
|
+
try {
|
|
70
|
+
msg = JSON.parse(line) as JsonRpcResponse;
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (typeof msg.id === "number" && this.pending.has(msg.id)) {
|
|
75
|
+
const resolver = this.pending.get(msg.id)!;
|
|
76
|
+
this.pending.delete(msg.id);
|
|
77
|
+
resolver(msg);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
rpc(method: string, params: Record<string, unknown>, timeoutMs = 10_000): Promise<Record<string, unknown>> {
|
|
83
|
+
const id = this.nextId++;
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
this.pending.delete(id);
|
|
87
|
+
reject(new Error(`Timeout waiting for ${method} (id=${id})`));
|
|
88
|
+
}, timeoutMs);
|
|
89
|
+
this.pending.set(id, (msg) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
if (msg.error) reject(new Error(`${method} failed: ${JSON.stringify(msg.error)}`));
|
|
92
|
+
else resolve(msg.result ?? {});
|
|
93
|
+
});
|
|
94
|
+
this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
notify(method: string, params?: Record<string, unknown>): void {
|
|
99
|
+
this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, ...(params ? { params } : {}) }) + "\n");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface McpHarness {
|
|
104
|
+
client: StdioRpcClient;
|
|
105
|
+
proc: ChildProcessWithoutNullStreams;
|
|
106
|
+
cleanup: () => Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface StartMcpOptions {
|
|
110
|
+
/**
|
|
111
|
+
* When true, point the transport at an unreachable rosbridge URL
|
|
112
|
+
* so `ensureConnected()` fails in milliseconds instead of hanging
|
|
113
|
+
* indefinitely waiting for rclnodejs / a real ROS daemon. Used by
|
|
114
|
+
* the Phase 1.g planner tests where we care about the COMPILE
|
|
115
|
+
* shape — the mission step's transport error is incidental.
|
|
116
|
+
*/
|
|
117
|
+
failFastTransport?: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Spawn the MCP server with a hermetic config + a fixture skill in
|
|
122
|
+
* skillPaths so the test can assert skill capabilities round-trip.
|
|
123
|
+
*/
|
|
124
|
+
async function startMcp(options: StartMcpOptions = {}): Promise<McpHarness> {
|
|
125
|
+
const tmp = await mkdtemp(path.join(tmpdir(), "agenticros-mcp-"));
|
|
126
|
+
|
|
127
|
+
// Plant a fixture skill that declares one capability.
|
|
128
|
+
const skillDir = path.join(tmp, "agenticros-skill-fixture");
|
|
129
|
+
await mkdir(skillDir, { recursive: true });
|
|
130
|
+
await writeFile(
|
|
131
|
+
path.join(skillDir, "package.json"),
|
|
132
|
+
JSON.stringify(
|
|
133
|
+
{
|
|
134
|
+
name: "agenticros-skill-fixture",
|
|
135
|
+
version: "0.0.1",
|
|
136
|
+
agenticros: {
|
|
137
|
+
id: "fixture",
|
|
138
|
+
capabilities: [
|
|
139
|
+
{
|
|
140
|
+
id: "fixture_verb",
|
|
141
|
+
verb: "fixture",
|
|
142
|
+
description: "Test-only capability planted by the MCP integration test.",
|
|
143
|
+
blocks_base: false,
|
|
144
|
+
interruptible: true,
|
|
145
|
+
},
|
|
146
|
+
// Phase 1.g — the planner needs find_object in the registry to
|
|
147
|
+
// compile "find a chair…" goals. We declare it here so the
|
|
148
|
+
// planner emits a real step; the actual dispatch errors out
|
|
149
|
+
// (no real transport in tests) but the planner output is what
|
|
150
|
+
// we want to assert against.
|
|
151
|
+
{
|
|
152
|
+
id: "find_object",
|
|
153
|
+
verb: "find",
|
|
154
|
+
description: "Test-only find_object capability for planner tests.",
|
|
155
|
+
blocks_base: false,
|
|
156
|
+
interruptible: true,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
null,
|
|
162
|
+
2,
|
|
163
|
+
),
|
|
164
|
+
"utf8",
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const configPath = path.join(tmp, "config.json");
|
|
168
|
+
await writeFile(
|
|
169
|
+
configPath,
|
|
170
|
+
JSON.stringify(
|
|
171
|
+
{
|
|
172
|
+
// No transport mode = defaults to local; tool dispatches before connect()
|
|
173
|
+
// so we never need a live transport for ros2_list_capabilities.
|
|
174
|
+
robot: { name: "Test Robot", namespace: "" },
|
|
175
|
+
skillPaths: [skillDir],
|
|
176
|
+
...(options.failFastTransport
|
|
177
|
+
? {
|
|
178
|
+
transport: { mode: "rosbridge" },
|
|
179
|
+
// 127.0.0.1:1 is conventionally unreachable; WebSocket creation
|
|
180
|
+
// fails immediately (ECONNREFUSED), so ensureConnected() throws
|
|
181
|
+
// fast instead of blocking the test.
|
|
182
|
+
rosbridge: { url: "ws://127.0.0.1:1" },
|
|
183
|
+
}
|
|
184
|
+
: {}),
|
|
185
|
+
},
|
|
186
|
+
null,
|
|
187
|
+
2,
|
|
188
|
+
),
|
|
189
|
+
"utf8",
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const proc = spawn("node", [MCP_ENTRY], {
|
|
193
|
+
env: {
|
|
194
|
+
...process.env,
|
|
195
|
+
AGENTICROS_CONFIG_PATH: configPath,
|
|
196
|
+
// Belt-and-suspenders: even if something tries to connect later, send it
|
|
197
|
+
// to a port that doesn't exist so it fails fast instead of hanging.
|
|
198
|
+
AGENTICROS_ROBOT_NAMESPACE: "test_robot",
|
|
199
|
+
},
|
|
200
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const client = new StdioRpcClient(proc);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
client,
|
|
207
|
+
proc,
|
|
208
|
+
cleanup: async () => {
|
|
209
|
+
proc.kill("SIGTERM");
|
|
210
|
+
await new Promise<void>((resolve) => {
|
|
211
|
+
const t = setTimeout(() => {
|
|
212
|
+
proc.kill("SIGKILL");
|
|
213
|
+
resolve();
|
|
214
|
+
}, 2000);
|
|
215
|
+
proc.on("exit", () => {
|
|
216
|
+
clearTimeout(t);
|
|
217
|
+
resolve();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
await rm(tmp, { recursive: true, force: true });
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function initialize(client: StdioRpcClient): Promise<void> {
|
|
226
|
+
await client.rpc("initialize", {
|
|
227
|
+
protocolVersion: "2024-11-05",
|
|
228
|
+
capabilities: {},
|
|
229
|
+
clientInfo: { name: "agenticros-mcp-test", version: "0.0.1" },
|
|
230
|
+
});
|
|
231
|
+
client.notify("notifications/initialized");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
test("mcp: server responds to initialize handshake", async () => {
|
|
235
|
+
const harness = await startMcp();
|
|
236
|
+
try {
|
|
237
|
+
const result = (await harness.client.rpc("initialize", {
|
|
238
|
+
protocolVersion: "2024-11-05",
|
|
239
|
+
capabilities: {},
|
|
240
|
+
clientInfo: { name: "agenticros-mcp-test", version: "0.0.1" },
|
|
241
|
+
})) as { serverInfo?: { name: string; version: string } };
|
|
242
|
+
assert.equal(result.serverInfo?.name, "agenticros");
|
|
243
|
+
} finally {
|
|
244
|
+
await harness.cleanup();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("mcp: tools/list contains ros2_list_capabilities", async () => {
|
|
249
|
+
const harness = await startMcp();
|
|
250
|
+
try {
|
|
251
|
+
await initialize(harness.client);
|
|
252
|
+
const list = (await harness.client.rpc("tools/list", {})) as {
|
|
253
|
+
tools: Array<{ name: string; description: string }>;
|
|
254
|
+
};
|
|
255
|
+
const names = list.tools.map((t) => t.name);
|
|
256
|
+
assert.ok(
|
|
257
|
+
names.includes("ros2_list_capabilities"),
|
|
258
|
+
`tools/list should include ros2_list_capabilities; got: ${names.join(", ")}`,
|
|
259
|
+
);
|
|
260
|
+
} finally {
|
|
261
|
+
await harness.cleanup();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("mcp: tools/call ros2_list_capabilities returns the expected shape", async () => {
|
|
266
|
+
const harness = await startMcp();
|
|
267
|
+
try {
|
|
268
|
+
await initialize(harness.client);
|
|
269
|
+
const result = (await harness.client.rpc("tools/call", {
|
|
270
|
+
name: "ros2_list_capabilities",
|
|
271
|
+
arguments: {},
|
|
272
|
+
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
|
|
273
|
+
|
|
274
|
+
assert.notEqual(result.isError, true, "tools/call should not return isError");
|
|
275
|
+
assert.ok(result.content?.[0]?.text, "tools/call must return text content");
|
|
276
|
+
|
|
277
|
+
const payload = JSON.parse(result.content[0].text) as {
|
|
278
|
+
success: boolean;
|
|
279
|
+
total: number;
|
|
280
|
+
intrinsic_count: number;
|
|
281
|
+
skill_count: number;
|
|
282
|
+
capabilities: Array<{
|
|
283
|
+
id: string;
|
|
284
|
+
verb: string;
|
|
285
|
+
source?: { kind: string; skillId?: string };
|
|
286
|
+
}>;
|
|
287
|
+
};
|
|
288
|
+
assert.equal(payload.success, true);
|
|
289
|
+
assert.equal(payload.intrinsic_count, 6, "should report exactly 6 intrinsic verbs");
|
|
290
|
+
assert.ok(payload.skill_count >= 1, "fixture skill should contribute at least one capability");
|
|
291
|
+
assert.equal(payload.total, payload.intrinsic_count + payload.skill_count);
|
|
292
|
+
|
|
293
|
+
// All 6 intrinsic verbs present.
|
|
294
|
+
const intrinsicIds = payload.capabilities
|
|
295
|
+
.filter((c) => c.source?.kind === "builtin")
|
|
296
|
+
.map((c) => c.id)
|
|
297
|
+
.sort();
|
|
298
|
+
assert.deepEqual(intrinsicIds, [
|
|
299
|
+
"drive_base",
|
|
300
|
+
"list_topics",
|
|
301
|
+
"measure_depth",
|
|
302
|
+
"publish_topic",
|
|
303
|
+
"subscribe_once",
|
|
304
|
+
"take_snapshot",
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
// Fixture skill capability round-trips.
|
|
308
|
+
const fixture = payload.capabilities.find((c) => c.id === "fixture_verb");
|
|
309
|
+
assert.ok(fixture, "fixture_verb should be in the response");
|
|
310
|
+
assert.equal(fixture.verb, "fixture");
|
|
311
|
+
assert.equal(fixture.source?.kind, "skill");
|
|
312
|
+
assert.equal(fixture.source?.skillId, "fixture");
|
|
313
|
+
} finally {
|
|
314
|
+
await harness.cleanup();
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("mcp: tools/list contains ros2_list_robots", async () => {
|
|
319
|
+
const harness = await startMcp();
|
|
320
|
+
try {
|
|
321
|
+
await initialize(harness.client);
|
|
322
|
+
const list = (await harness.client.rpc("tools/list", {})) as {
|
|
323
|
+
tools: Array<{ name: string }>;
|
|
324
|
+
};
|
|
325
|
+
const names = list.tools.map((t) => t.name);
|
|
326
|
+
assert.ok(
|
|
327
|
+
names.includes("ros2_list_robots"),
|
|
328
|
+
`tools/list should include ros2_list_robots; got: ${names.join(", ")}`,
|
|
329
|
+
);
|
|
330
|
+
} finally {
|
|
331
|
+
await harness.cleanup();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("mcp: tools/call ros2_list_robots returns the expected shape (legacy fallback)", async () => {
|
|
336
|
+
const harness = await startMcp();
|
|
337
|
+
try {
|
|
338
|
+
await initialize(harness.client);
|
|
339
|
+
const result = (await harness.client.rpc("tools/call", {
|
|
340
|
+
name: "ros2_list_robots",
|
|
341
|
+
arguments: {},
|
|
342
|
+
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
|
|
343
|
+
|
|
344
|
+
assert.notEqual(result.isError, true);
|
|
345
|
+
const payload = JSON.parse(result.content[0].text) as {
|
|
346
|
+
success: boolean;
|
|
347
|
+
total: number;
|
|
348
|
+
active_robot_id: string;
|
|
349
|
+
robots: Array<{ id: string; name: string; namespace: string; source: string }>;
|
|
350
|
+
};
|
|
351
|
+
assert.equal(payload.success, true);
|
|
352
|
+
// The hermetic test config sets robot.namespace via AGENTICROS_ROBOT_NAMESPACE
|
|
353
|
+
// to "test_robot" — so the legacy fallback should synthesize that id.
|
|
354
|
+
assert.ok(payload.total >= 1);
|
|
355
|
+
const active = payload.robots.find((r) => r.id === payload.active_robot_id);
|
|
356
|
+
assert.ok(active, "active_robot_id should refer to an entry in robots[]");
|
|
357
|
+
} finally {
|
|
358
|
+
await harness.cleanup();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("mcp: ros2_list_robots works WITHOUT a ROS transport (offline-safe)", async () => {
|
|
363
|
+
// Sibling regression test to the ros2_list_capabilities offline check —
|
|
364
|
+
// ros2_list_robots reads only local config, must never block on transport
|
|
365
|
+
// connect. If somebody drops it from NO_TRANSPORT_TOOL_NAMES the outer
|
|
366
|
+
// ensureConnected() gate will fire and this test will time out.
|
|
367
|
+
const harness = await startMcp();
|
|
368
|
+
try {
|
|
369
|
+
await initialize(harness.client);
|
|
370
|
+
const t0 = Date.now();
|
|
371
|
+
await harness.client.rpc(
|
|
372
|
+
"tools/call",
|
|
373
|
+
{ name: "ros2_list_robots", arguments: {} },
|
|
374
|
+
5000,
|
|
375
|
+
);
|
|
376
|
+
const elapsed = Date.now() - t0;
|
|
377
|
+
assert.ok(
|
|
378
|
+
elapsed < 3000,
|
|
379
|
+
`tool should return in <3s without transport; took ${elapsed}ms`,
|
|
380
|
+
);
|
|
381
|
+
} finally {
|
|
382
|
+
await harness.cleanup();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("mcp: tools/list contains ros2_find_robots_for (Phase 1.e)", async () => {
|
|
387
|
+
// Pins the new fleet-filter tool into the MCP catalog so an agent
|
|
388
|
+
// listing /tools sees the verb-aware finder alongside list/discover.
|
|
389
|
+
const harness = await startMcp();
|
|
390
|
+
try {
|
|
391
|
+
await initialize(harness.client);
|
|
392
|
+
const list = (await harness.client.rpc("tools/list", {})) as {
|
|
393
|
+
tools: Array<{
|
|
394
|
+
name: string;
|
|
395
|
+
inputSchema?: { properties?: Record<string, { type?: string }> };
|
|
396
|
+
}>;
|
|
397
|
+
};
|
|
398
|
+
const tool = list.tools.find((t) => t.name === "ros2_find_robots_for");
|
|
399
|
+
assert.ok(tool, "ros2_find_robots_for must be registered");
|
|
400
|
+
// All three filter axes from the spec must be advertised so the LLM
|
|
401
|
+
// can plan against them without guessing parameter names.
|
|
402
|
+
const props = tool!.inputSchema?.properties ?? {};
|
|
403
|
+
assert.ok(props["capability"], "must advertise 'capability' parameter");
|
|
404
|
+
assert.ok(props["kind"], "must advertise 'kind' parameter");
|
|
405
|
+
assert.ok(props["online"], "must advertise 'online' parameter");
|
|
406
|
+
assert.equal(props["online"].type, "boolean");
|
|
407
|
+
} finally {
|
|
408
|
+
await harness.cleanup();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("mcp: ros2_find_robots_for works WITHOUT a ROS transport (offline-safe when 'online' is omitted)", async () => {
|
|
413
|
+
// The fleet filter MUST stay config-only by default — agents call it
|
|
414
|
+
// mid-conversation for static planning. The transport is only needed
|
|
415
|
+
// for the live online check. If somebody removes it from
|
|
416
|
+
// NO_TRANSPORT_TOOL_NAMES the outer ensureConnected() will block and
|
|
417
|
+
// this test will time out.
|
|
418
|
+
const harness = await startMcp();
|
|
419
|
+
try {
|
|
420
|
+
await initialize(harness.client);
|
|
421
|
+
const t0 = Date.now();
|
|
422
|
+
const result = (await harness.client.rpc(
|
|
423
|
+
"tools/call",
|
|
424
|
+
{ name: "ros2_find_robots_for", arguments: {} },
|
|
425
|
+
5000,
|
|
426
|
+
)) as { content: Array<{ type: string; text: string }>; isError?: boolean };
|
|
427
|
+
const elapsed = Date.now() - t0;
|
|
428
|
+
assert.ok(elapsed < 3000, `should return in <3s without transport; took ${elapsed}ms`);
|
|
429
|
+
assert.notEqual(result.isError, true);
|
|
430
|
+
const payload = JSON.parse(result.content[0].text) as {
|
|
431
|
+
success: boolean;
|
|
432
|
+
total: number;
|
|
433
|
+
query: Record<string, unknown>;
|
|
434
|
+
robots: Array<{ id: string; online: boolean | null }>;
|
|
435
|
+
};
|
|
436
|
+
assert.equal(payload.success, true);
|
|
437
|
+
// No online filter → online status is null on every match.
|
|
438
|
+
for (const r of payload.robots) {
|
|
439
|
+
assert.equal(r.online, null, "online should be null when no online filter was applied");
|
|
440
|
+
}
|
|
441
|
+
} finally {
|
|
442
|
+
await harness.cleanup();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("mcp: ros2_find_robots_for with unknown capability filters everyone out (no false positives)", async () => {
|
|
447
|
+
const harness = await startMcp();
|
|
448
|
+
try {
|
|
449
|
+
await initialize(harness.client);
|
|
450
|
+
const result = (await harness.client.rpc("tools/call", {
|
|
451
|
+
name: "ros2_find_robots_for",
|
|
452
|
+
arguments: { capability: "no_such_verb_should_never_exist" },
|
|
453
|
+
})) as { content: Array<{ type: string; text: string }>; isError?: boolean };
|
|
454
|
+
const payload = JSON.parse(result.content[0].text) as { success: boolean; total: number };
|
|
455
|
+
assert.equal(payload.success, true);
|
|
456
|
+
assert.equal(payload.total, 0);
|
|
457
|
+
} finally {
|
|
458
|
+
await harness.cleanup();
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("mcp: ros2_find_robots_for kind filter is case-insensitive exact match", async () => {
|
|
463
|
+
const harness = await startMcp();
|
|
464
|
+
try {
|
|
465
|
+
await initialize(harness.client);
|
|
466
|
+
// The hermetic test config synthesizes one AMR via the legacy fallback.
|
|
467
|
+
const matches = (await harness.client.rpc("tools/call", {
|
|
468
|
+
name: "ros2_find_robots_for",
|
|
469
|
+
arguments: { kind: "AMR" },
|
|
470
|
+
})) as { content: Array<{ text: string }> };
|
|
471
|
+
const payload = JSON.parse(matches.content[0].text) as { total: number };
|
|
472
|
+
assert.ok(payload.total >= 1, "case-insensitive AMR should match the legacy entry");
|
|
473
|
+
|
|
474
|
+
// A non-existent kind should match nothing.
|
|
475
|
+
const none = (await harness.client.rpc("tools/call", {
|
|
476
|
+
name: "ros2_find_robots_for",
|
|
477
|
+
arguments: { kind: "submersible" },
|
|
478
|
+
})) as { content: Array<{ text: string }> };
|
|
479
|
+
const empty = JSON.parse(none.content[0].text) as { total: number };
|
|
480
|
+
assert.equal(empty.total, 0);
|
|
481
|
+
} finally {
|
|
482
|
+
await harness.cleanup();
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("mcp: tools/list contains ros2_discover_robots (Phase 1.d discovery)", async () => {
|
|
487
|
+
const harness = await startMcp();
|
|
488
|
+
try {
|
|
489
|
+
await initialize(harness.client);
|
|
490
|
+
const list = (await harness.client.rpc("tools/list", {})) as {
|
|
491
|
+
tools: Array<{ name: string; inputSchema?: { properties?: Record<string, unknown> } }>;
|
|
492
|
+
};
|
|
493
|
+
const names = list.tools.map((t) => t.name);
|
|
494
|
+
assert.ok(
|
|
495
|
+
names.includes("ros2_discover_robots"),
|
|
496
|
+
`tools/list should include ros2_discover_robots; got: ${names.join(", ")}`,
|
|
497
|
+
);
|
|
498
|
+
// Discovery returns the whole fleet view — it should NOT take robot_id
|
|
499
|
+
// (no per-robot scoping makes sense; it always reports everything seen).
|
|
500
|
+
const tool = list.tools.find((t) => t.name === "ros2_discover_robots");
|
|
501
|
+
const props = tool?.inputSchema?.properties ?? {};
|
|
502
|
+
assert.ok(
|
|
503
|
+
!Object.prototype.hasOwnProperty.call(props, "robot_id"),
|
|
504
|
+
"ros2_discover_robots should NOT advertise robot_id — it's a fleet-wide tool",
|
|
505
|
+
);
|
|
506
|
+
} finally {
|
|
507
|
+
await harness.cleanup();
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("mcp: tools/list contains run_mission", async () => {
|
|
512
|
+
const harness = await startMcp();
|
|
513
|
+
try {
|
|
514
|
+
await initialize(harness.client);
|
|
515
|
+
const list = (await harness.client.rpc("tools/list", {})) as {
|
|
516
|
+
tools: Array<{ name: string }>;
|
|
517
|
+
};
|
|
518
|
+
const names = list.tools.map((t) => t.name);
|
|
519
|
+
assert.ok(names.includes("run_mission"), `tools/list should include run_mission; got: ${names.join(", ")}`);
|
|
520
|
+
} finally {
|
|
521
|
+
await harness.cleanup();
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("mcp: run_mission rejects malformed input cleanly (no transport needed)", async () => {
|
|
526
|
+
const harness = await startMcp();
|
|
527
|
+
try {
|
|
528
|
+
await initialize(harness.client);
|
|
529
|
+
// Missing `mission` argument entirely → tool should return isError with
|
|
530
|
+
// a useful message rather than crash the server.
|
|
531
|
+
const result = (await harness.client.rpc("tools/call", {
|
|
532
|
+
name: "run_mission",
|
|
533
|
+
arguments: {},
|
|
534
|
+
})) as { content: Array<{ text: string }>; isError?: boolean };
|
|
535
|
+
assert.equal(result.isError, true);
|
|
536
|
+
assert.ok(result.content[0].text.toLowerCase().includes("mission"));
|
|
537
|
+
} finally {
|
|
538
|
+
await harness.cleanup();
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("mcp: ros2_list_capabilities works WITHOUT a ROS transport (offline-safe)", async () => {
|
|
543
|
+
// This is the regression test for NO_TRANSPORT_TOOL_NAMES wiring. If
|
|
544
|
+
// somebody drops `ros2_list_capabilities` from that set, the outer
|
|
545
|
+
// `ensureConnected()` will block on a Zenoh that isn't there and this
|
|
546
|
+
// call will time out. We assert it completes well under the timeout.
|
|
547
|
+
const harness = await startMcp();
|
|
548
|
+
try {
|
|
549
|
+
await initialize(harness.client);
|
|
550
|
+
const t0 = Date.now();
|
|
551
|
+
await harness.client.rpc(
|
|
552
|
+
"tools/call",
|
|
553
|
+
{ name: "ros2_list_capabilities", arguments: {} },
|
|
554
|
+
5000, // intentionally short — must NOT trigger transport connect path
|
|
555
|
+
);
|
|
556
|
+
const elapsed = Date.now() - t0;
|
|
557
|
+
assert.ok(
|
|
558
|
+
elapsed < 3000,
|
|
559
|
+
`tool should return in <3s without transport; took ${elapsed}ms (likely waiting on transport connect)`,
|
|
560
|
+
);
|
|
561
|
+
} finally {
|
|
562
|
+
await harness.cleanup();
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// --- Phase 1.d-extend: per-tool robot_id error path tests ---
|
|
567
|
+
//
|
|
568
|
+
// These pin down the contract that every tool that accepts robot_id
|
|
569
|
+
// rejects unknown ids with a clean, self-correctable error response.
|
|
570
|
+
// We exercise this via ros2_list_capabilities (transport-free, so it
|
|
571
|
+
// works in this hermetic fixture) and via run_mission's mission.robot_id
|
|
572
|
+
// validator. The other 8 ROS2 tools share the same resolveRobotFromArgs
|
|
573
|
+
// path so the contract is the same; we don't repeat for each.
|
|
574
|
+
|
|
575
|
+
test("mcp: ros2_list_capabilities with unknown robot_id returns clean tool error", async () => {
|
|
576
|
+
const harness = await startMcp();
|
|
577
|
+
try {
|
|
578
|
+
await initialize(harness.client);
|
|
579
|
+
const result = (await harness.client.rpc("tools/call", {
|
|
580
|
+
name: "ros2_list_capabilities",
|
|
581
|
+
arguments: { robot_id: "nonexistent-robot" },
|
|
582
|
+
})) as { content: Array<{ text: string }>; isError?: boolean };
|
|
583
|
+
assert.equal(result.isError, true, "unknown robot_id must produce isError=true");
|
|
584
|
+
const text = result.content[0].text;
|
|
585
|
+
assert.ok(text.includes("nonexistent-robot"), `error text should mention the bad id (got: ${text})`);
|
|
586
|
+
assert.ok(
|
|
587
|
+
text.toLowerCase().includes("ros2_list_robots"),
|
|
588
|
+
`error text should recommend ros2_list_robots for self-correction (got: ${text})`,
|
|
589
|
+
);
|
|
590
|
+
} finally {
|
|
591
|
+
await harness.cleanup();
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("mcp: ros2_list_capabilities with valid robot_id returns the same response (today)", async () => {
|
|
596
|
+
// The hermetic config has the legacy single-robot fallback id
|
|
597
|
+
// "test_robot" (derived from AGENTICROS_ROBOT_NAMESPACE). Passing it
|
|
598
|
+
// should succeed and produce the same payload shape as no argument.
|
|
599
|
+
const harness = await startMcp();
|
|
600
|
+
try {
|
|
601
|
+
await initialize(harness.client);
|
|
602
|
+
const result = (await harness.client.rpc("tools/call", {
|
|
603
|
+
name: "ros2_list_capabilities",
|
|
604
|
+
arguments: { robot_id: "test_robot" },
|
|
605
|
+
})) as { content: Array<{ text: string }>; isError?: boolean };
|
|
606
|
+
assert.notEqual(result.isError, true, "valid robot_id must not surface as error");
|
|
607
|
+
const payload = JSON.parse(result.content[0].text) as { success: boolean; total: number };
|
|
608
|
+
assert.equal(payload.success, true);
|
|
609
|
+
assert.ok(payload.total >= 6);
|
|
610
|
+
} finally {
|
|
611
|
+
await harness.cleanup();
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("mcp: run_mission with unknown mission.robot_id is rejected before any step runs", async () => {
|
|
616
|
+
const harness = await startMcp();
|
|
617
|
+
try {
|
|
618
|
+
await initialize(harness.client);
|
|
619
|
+
const result = (await harness.client.rpc("tools/call", {
|
|
620
|
+
name: "run_mission",
|
|
621
|
+
arguments: {
|
|
622
|
+
mission: {
|
|
623
|
+
name: "should never start",
|
|
624
|
+
robot_id: "no-such-robot",
|
|
625
|
+
steps: [
|
|
626
|
+
{ id: "a", capability: "drive_base", inputs: { linear_x: 0.1 } },
|
|
627
|
+
],
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
})) as { content: Array<{ text: string }>; isError?: boolean };
|
|
631
|
+
assert.equal(result.isError, true);
|
|
632
|
+
const text = result.content[0].text;
|
|
633
|
+
assert.ok(text.includes("no-such-robot"));
|
|
634
|
+
assert.ok(
|
|
635
|
+
text.toLowerCase().includes("ros2_list_robots"),
|
|
636
|
+
`mission-level robot_id error should recommend ros2_list_robots (got: ${text})`,
|
|
637
|
+
);
|
|
638
|
+
} finally {
|
|
639
|
+
await harness.cleanup();
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("mcp: every ROS2 tool advertises optional robot_id in its inputSchema", async () => {
|
|
644
|
+
// Regression test that catches accidental schema regressions when
|
|
645
|
+
// we add or rename tools later. Every tool that accepts robot_id
|
|
646
|
+
// should expose the property; ros2_list_topics and ros2_list_robots
|
|
647
|
+
// are exempt (global lookups, no per-robot routing).
|
|
648
|
+
const harness = await startMcp();
|
|
649
|
+
try {
|
|
650
|
+
await initialize(harness.client);
|
|
651
|
+
const list = (await harness.client.rpc("tools/list", {})) as {
|
|
652
|
+
tools: Array<{ name: string; inputSchema?: { properties?: Record<string, unknown> } }>;
|
|
653
|
+
};
|
|
654
|
+
const expected = [
|
|
655
|
+
"ros2_list_capabilities",
|
|
656
|
+
"ros2_publish",
|
|
657
|
+
"ros2_subscribe_once",
|
|
658
|
+
"ros2_service_call",
|
|
659
|
+
"ros2_action_goal",
|
|
660
|
+
"ros2_param_get",
|
|
661
|
+
"ros2_param_set",
|
|
662
|
+
"ros2_camera_snapshot",
|
|
663
|
+
"ros2_depth_distance",
|
|
664
|
+
// Phase 1.d-extend: skill tools must also route through robot_id so
|
|
665
|
+
// multi-robot deployments can target a specific robot's loop.
|
|
666
|
+
"ros2_follow_me_start",
|
|
667
|
+
"ros2_follow_me_stop",
|
|
668
|
+
"ros2_follow_me_status",
|
|
669
|
+
"ros2_follow_me_set_distance",
|
|
670
|
+
"ros2_follow_me_set_target",
|
|
671
|
+
"ros2_find_object",
|
|
672
|
+
];
|
|
673
|
+
for (const name of expected) {
|
|
674
|
+
const tool = list.tools.find((t) => t.name === name);
|
|
675
|
+
assert.ok(tool, `${name} should be in tools/list`);
|
|
676
|
+
const props = tool.inputSchema?.properties ?? {};
|
|
677
|
+
assert.ok(
|
|
678
|
+
Object.prototype.hasOwnProperty.call(props, "robot_id"),
|
|
679
|
+
`${name} should advertise an optional robot_id parameter; got properties: ${Object.keys(props).join(", ")}`,
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
// ros2_list_robots itself does NOT take robot_id.
|
|
683
|
+
const listRobotsTool = list.tools.find((t) => t.name === "ros2_list_robots");
|
|
684
|
+
assert.ok(listRobotsTool);
|
|
685
|
+
const lrProps = listRobotsTool.inputSchema?.properties ?? {};
|
|
686
|
+
assert.ok(
|
|
687
|
+
!Object.prototype.hasOwnProperty.call(lrProps, "robot_id"),
|
|
688
|
+
"ros2_list_robots should NOT take robot_id (it returns the full list)",
|
|
689
|
+
);
|
|
690
|
+
} finally {
|
|
691
|
+
await harness.cleanup();
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// --- Phase 1.f: mission_cancel surface + semantics ---
|
|
696
|
+
//
|
|
697
|
+
// These tests pin the adapter contract for the cancel tool: it must
|
|
698
|
+
// be in the catalog, advertise mission_id/reason, work without ROS
|
|
699
|
+
// transport (it only mutates an in-process registry), and behave
|
|
700
|
+
// idempotently / safely on unknown ids.
|
|
701
|
+
|
|
702
|
+
test("mcp: tools/list contains mission_cancel (Phase 1.f)", async () => {
|
|
703
|
+
const harness = await startMcp();
|
|
704
|
+
try {
|
|
705
|
+
await initialize(harness.client);
|
|
706
|
+
const list = (await harness.client.rpc("tools/list", {})) as {
|
|
707
|
+
tools: Array<{
|
|
708
|
+
name: string;
|
|
709
|
+
inputSchema?: { properties?: Record<string, { type?: string }>; required?: string[] };
|
|
710
|
+
}>;
|
|
711
|
+
};
|
|
712
|
+
const tool = list.tools.find((t) => t.name === "mission_cancel");
|
|
713
|
+
assert.ok(tool, "mission_cancel must be registered alongside run_mission");
|
|
714
|
+
const props = tool!.inputSchema?.properties ?? {};
|
|
715
|
+
assert.ok(props["mission_id"], "must advertise 'mission_id'");
|
|
716
|
+
assert.ok(props["reason"], "must advertise optional 'reason'");
|
|
717
|
+
assert.deepEqual(
|
|
718
|
+
tool!.inputSchema?.required,
|
|
719
|
+
["mission_id"],
|
|
720
|
+
"mission_id should be the only required field",
|
|
721
|
+
);
|
|
722
|
+
} finally {
|
|
723
|
+
await harness.cleanup();
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test("mcp: mission_cancel works WITHOUT a ROS transport (in-process registry only)", async () => {
|
|
728
|
+
// mission_cancel never talks to ROS — it just flips a token in the
|
|
729
|
+
// in-process MissionRegistry. If somebody drops it from
|
|
730
|
+
// NO_TRANSPORT_TOOL_NAMES the outer ensureConnected() gate fires
|
|
731
|
+
// and the call hangs until the test timeout. We assert the call
|
|
732
|
+
// returns fast even though no zenohd is up.
|
|
733
|
+
const harness = await startMcp();
|
|
734
|
+
try {
|
|
735
|
+
await initialize(harness.client);
|
|
736
|
+
const t0 = Date.now();
|
|
737
|
+
const result = (await harness.client.rpc(
|
|
738
|
+
"tools/call",
|
|
739
|
+
{ name: "mission_cancel", arguments: { mission_id: "mn_does_not_exist" } },
|
|
740
|
+
5000,
|
|
741
|
+
)) as { content: Array<{ text: string }>; isError?: boolean };
|
|
742
|
+
const elapsed = Date.now() - t0;
|
|
743
|
+
assert.ok(elapsed < 3000, `must return in <3s without transport; took ${elapsed}ms`);
|
|
744
|
+
assert.notEqual(result.isError, true);
|
|
745
|
+
const payload = JSON.parse(result.content[0].text) as {
|
|
746
|
+
success: boolean;
|
|
747
|
+
mission_id: string;
|
|
748
|
+
found: boolean;
|
|
749
|
+
already_cancelled: boolean;
|
|
750
|
+
};
|
|
751
|
+
assert.equal(payload.success, true);
|
|
752
|
+
assert.equal(payload.found, false, "unknown mission_id should report found:false");
|
|
753
|
+
assert.equal(payload.already_cancelled, false);
|
|
754
|
+
assert.equal(payload.mission_id, "mn_does_not_exist");
|
|
755
|
+
} finally {
|
|
756
|
+
await harness.cleanup();
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("mcp: mission_cancel rejects empty / missing mission_id with a useful error", async () => {
|
|
761
|
+
const harness = await startMcp();
|
|
762
|
+
try {
|
|
763
|
+
await initialize(harness.client);
|
|
764
|
+
// Empty string
|
|
765
|
+
const empty = (await harness.client.rpc("tools/call", {
|
|
766
|
+
name: "mission_cancel",
|
|
767
|
+
arguments: { mission_id: " " },
|
|
768
|
+
})) as { content: Array<{ text: string }>; isError?: boolean };
|
|
769
|
+
assert.equal(empty.isError, true);
|
|
770
|
+
assert.ok(empty.content[0].text.toLowerCase().includes("mission_id"));
|
|
771
|
+
|
|
772
|
+
// Missing altogether
|
|
773
|
+
const missing = (await harness.client.rpc("tools/call", {
|
|
774
|
+
name: "mission_cancel",
|
|
775
|
+
arguments: {},
|
|
776
|
+
})) as { content: Array<{ text: string }>; isError?: boolean };
|
|
777
|
+
assert.equal(missing.isError, true);
|
|
778
|
+
assert.ok(missing.content[0].text.toLowerCase().includes("mission_id"));
|
|
779
|
+
} finally {
|
|
780
|
+
await harness.cleanup();
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("mcp: run_mission returns a mission_id in the compact result payload (Phase 1.f)", async () => {
|
|
785
|
+
// The agent needs the mission_id back so it can later call
|
|
786
|
+
// mission_cancel. Even a mission that fails its only step (because
|
|
787
|
+
// it dispatches a capability that needs a real transport here)
|
|
788
|
+
// must still surface mission_id in its compact JSON payload — this
|
|
789
|
+
// is the wire the cancel UX hangs on.
|
|
790
|
+
const harness = await startMcp();
|
|
791
|
+
try {
|
|
792
|
+
await initialize(harness.client);
|
|
793
|
+
const result = (await harness.client.rpc("tools/call", {
|
|
794
|
+
name: "run_mission",
|
|
795
|
+
arguments: {
|
|
796
|
+
mission: {
|
|
797
|
+
name: "should yield a mission_id",
|
|
798
|
+
steps: [
|
|
799
|
+
// Step uses a capability the runner refuses (no binding for
|
|
800
|
+
// a made-up capability) so we don't need any ROS transport.
|
|
801
|
+
{ id: "noop", capability: "fixture_verb" },
|
|
802
|
+
],
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
}, 10_000)) as { content: Array<{ text: string }>; isError?: boolean };
|
|
806
|
+
// The text payload is "<summary>\n<json>" — pull the JSON line.
|
|
807
|
+
const lines = result.content[0].text.split("\n");
|
|
808
|
+
const jsonLine = lines.find((l) => l.trim().startsWith("{")) ?? "";
|
|
809
|
+
const payload = JSON.parse(jsonLine) as {
|
|
810
|
+
mission_id?: string;
|
|
811
|
+
status: string;
|
|
812
|
+
steps: Array<{ status: string }>;
|
|
813
|
+
};
|
|
814
|
+
assert.ok(
|
|
815
|
+
typeof payload.mission_id === "string" && payload.mission_id.startsWith("mn_"),
|
|
816
|
+
`compact result should include a mission_id; got: ${JSON.stringify(payload)}`,
|
|
817
|
+
);
|
|
818
|
+
} finally {
|
|
819
|
+
await harness.cleanup();
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// --- Phase 1.g: run_mission { goal } natural-language compile path ---
|
|
824
|
+
//
|
|
825
|
+
// These tests pin the adapter-level contract for the NL planner:
|
|
826
|
+
// run_mission must accept `goal` as an alternative to `mission`,
|
|
827
|
+
// surface the compiled plan + candidates in its response, and reject
|
|
828
|
+
// uncompilable goals with a helpful suggestions list.
|
|
829
|
+
|
|
830
|
+
test("mcp: tools/list run_mission advertises both 'mission' AND 'goal' parameters (Phase 1.g)", async () => {
|
|
831
|
+
const harness = await startMcp();
|
|
832
|
+
try {
|
|
833
|
+
await initialize(harness.client);
|
|
834
|
+
const list = (await harness.client.rpc("tools/list", {})) as {
|
|
835
|
+
tools: Array<{
|
|
836
|
+
name: string;
|
|
837
|
+
inputSchema?: {
|
|
838
|
+
properties?: Record<string, { type?: string }>;
|
|
839
|
+
required?: string[];
|
|
840
|
+
};
|
|
841
|
+
}>;
|
|
842
|
+
};
|
|
843
|
+
const tool = list.tools.find((t) => t.name === "run_mission");
|
|
844
|
+
assert.ok(tool, "run_mission must be in the catalog");
|
|
845
|
+
const props = tool!.inputSchema?.properties ?? {};
|
|
846
|
+
assert.ok(props["mission"], "must still accept 'mission'");
|
|
847
|
+
assert.ok(props["goal"], "must also accept natural-language 'goal' (Phase 1.g)");
|
|
848
|
+
assert.ok(props["robot_id"], "must accept top-level robot_id when goal is used");
|
|
849
|
+
// Neither must be required individually — the handler enforces
|
|
850
|
+
// "at least one of mission/goal" at runtime.
|
|
851
|
+
assert.deepEqual(tool!.inputSchema?.required ?? [], []);
|
|
852
|
+
} finally {
|
|
853
|
+
await harness.cleanup();
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test("mcp: run_mission { goal } compiles + executes without an explicit mission (Phase 1.g)", async () => {
|
|
858
|
+
// The planner is rule-based + deterministic; we don't need a real
|
|
859
|
+
// ROS transport for this — the step uses `take_snapshot` which has
|
|
860
|
+
// a binding but will fail at dispatch (no zenohd here). The point
|
|
861
|
+
// of the test is the COMPILE: the response must echo planner info
|
|
862
|
+
// and a mission_id even though the step itself errors.
|
|
863
|
+
// failFastTransport=true points the rosbridge URL at an unreachable
|
|
864
|
+
// port so ensureConnected() throws in ms instead of hanging.
|
|
865
|
+
const harness = await startMcp({ failFastTransport: true });
|
|
866
|
+
try {
|
|
867
|
+
await initialize(harness.client);
|
|
868
|
+
const result = (await harness.client.rpc(
|
|
869
|
+
"tools/call",
|
|
870
|
+
{ name: "run_mission", arguments: { goal: "take a picture" } },
|
|
871
|
+
10_000,
|
|
872
|
+
)) as { content: Array<{ text: string }>; isError?: boolean };
|
|
873
|
+
const text = result.content[0].text;
|
|
874
|
+
const lines = text.split("\n");
|
|
875
|
+
const jsonLine = lines.find((l) => l.trim().startsWith("{")) ?? "";
|
|
876
|
+
if (!jsonLine) {
|
|
877
|
+
throw new Error(`expected JSON line in response; got:\n${text}`);
|
|
878
|
+
}
|
|
879
|
+
const payload = JSON.parse(jsonLine) as {
|
|
880
|
+
mission_id?: string;
|
|
881
|
+
planner?: {
|
|
882
|
+
compiled_from_goal: string;
|
|
883
|
+
candidates: Array<{ capability_id: string }>;
|
|
884
|
+
};
|
|
885
|
+
steps: Array<{ capability: string }>;
|
|
886
|
+
};
|
|
887
|
+
assert.equal(payload.planner?.compiled_from_goal, "take a picture");
|
|
888
|
+
assert.ok(payload.planner?.candidates && payload.planner.candidates.length >= 1);
|
|
889
|
+
assert.equal(payload.planner!.candidates[0].capability_id, "take_snapshot");
|
|
890
|
+
// The compiled plan must have actually run (or at least started).
|
|
891
|
+
assert.equal(payload.steps[0].capability, "take_snapshot");
|
|
892
|
+
assert.ok(typeof payload.mission_id === "string" && payload.mission_id.startsWith("mn_"));
|
|
893
|
+
} finally {
|
|
894
|
+
await harness.cleanup();
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test("mcp: run_mission { goal } compound 'find a chair and drive toward it' produces a 2-step plan (Phase 1.g)", async () => {
|
|
899
|
+
const harness = await startMcp({ failFastTransport: true });
|
|
900
|
+
try {
|
|
901
|
+
await initialize(harness.client);
|
|
902
|
+
const result = (await harness.client.rpc(
|
|
903
|
+
"tools/call",
|
|
904
|
+
{ name: "run_mission", arguments: { goal: "find a chair and drive toward it" } },
|
|
905
|
+
10_000,
|
|
906
|
+
)) as { content: Array<{ text: string }>; isError?: boolean };
|
|
907
|
+
const lines = result.content[0].text.split("\n");
|
|
908
|
+
const jsonLine = lines.find((l) => l.trim().startsWith("{")) ?? "";
|
|
909
|
+
const payload = JSON.parse(jsonLine) as {
|
|
910
|
+
planner?: { candidates: Array<{ capability_id: string }> };
|
|
911
|
+
steps_total?: number;
|
|
912
|
+
steps: Array<{ id: string; capability: string }>;
|
|
913
|
+
};
|
|
914
|
+
assert.equal(payload.planner?.candidates.length, 2);
|
|
915
|
+
assert.equal(payload.steps_total, 2);
|
|
916
|
+
assert.equal(payload.steps[0].capability, "find_object");
|
|
917
|
+
assert.equal(payload.steps[0].id, "find");
|
|
918
|
+
assert.equal(payload.steps[1].capability, "drive_base");
|
|
919
|
+
assert.equal(payload.steps[1].id, "approach");
|
|
920
|
+
} finally {
|
|
921
|
+
await harness.cleanup();
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("mcp: run_mission { goal: 'paint the wall' } surfaces a clean compile error with suggestions (Phase 1.g)", async () => {
|
|
926
|
+
// The planner can't map "paint" to any capability — the response
|
|
927
|
+
// must isError=true with a structured payload (error + suggestions)
|
|
928
|
+
// so the LLM can self-correct without an extra round-trip.
|
|
929
|
+
const harness = await startMcp();
|
|
930
|
+
try {
|
|
931
|
+
await initialize(harness.client);
|
|
932
|
+
const result = (await harness.client.rpc(
|
|
933
|
+
"tools/call",
|
|
934
|
+
{ name: "run_mission", arguments: { goal: "paint the wall blue" } },
|
|
935
|
+
10_000,
|
|
936
|
+
)) as { content: Array<{ text: string }>; isError?: boolean };
|
|
937
|
+
assert.equal(result.isError, true);
|
|
938
|
+
const payload = JSON.parse(result.content[0].text) as {
|
|
939
|
+
success: boolean;
|
|
940
|
+
error: string;
|
|
941
|
+
suggestions: string[];
|
|
942
|
+
};
|
|
943
|
+
assert.equal(payload.success, false);
|
|
944
|
+
assert.ok(payload.error.toLowerCase().includes("couldn't compile"));
|
|
945
|
+
assert.ok(Array.isArray(payload.suggestions) && payload.suggestions.length > 0);
|
|
946
|
+
} finally {
|
|
947
|
+
await harness.cleanup();
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("mcp: run_mission with NEITHER mission nor goal returns a clear error (Phase 1.g)", async () => {
|
|
952
|
+
const harness = await startMcp();
|
|
953
|
+
try {
|
|
954
|
+
await initialize(harness.client);
|
|
955
|
+
const result = (await harness.client.rpc("tools/call", {
|
|
956
|
+
name: "run_mission",
|
|
957
|
+
arguments: {},
|
|
958
|
+
})) as { content: Array<{ text: string }>; isError?: boolean };
|
|
959
|
+
assert.equal(result.isError, true);
|
|
960
|
+
const text = result.content[0].text.toLowerCase();
|
|
961
|
+
assert.ok(text.includes("mission") && text.includes("goal"), `error must mention both options; got: ${result.content[0].text}`);
|
|
962
|
+
} finally {
|
|
963
|
+
await harness.cleanup();
|
|
964
|
+
}
|
|
965
|
+
});
|