/** * Smoke test for the AgenticROS OpenClaw plugin. * * Loads the compiled plugin via require() — the same path OpenClaw's plugin * host uses — and asserts the Phase 1 capability surface registers correctly: * * 1. plugin.register() returns synchronously with all 10 base tools * registered (no Promise tail that would drop tools from OpenClaw's * sync snapshot). * 2. `ros2_list_capabilities` is one of those tools. * 3. Its execute() returns a well-formed payload containing the 6 * intrinsic verbs in BUILTIN_CAPABILITIES. * 4. The plugin manifest's contracts.tools allowlist includes * `ros2_list_capabilities` — without this, OpenClaw's tool profile * would silently filter it out. * * This is the regression net for the cascade we hit on 2026-06-10 where a * stale plugin-deploy meant the tool registered fine in the workspace but * not in the live gateway, and `sync-skill-tools.mjs` separately * overwrote the manifest because its hardcoded CORE_TOOLS list lagged the * actual core toolset. */ import { test } from "node:test"; import assert from "node:assert/strict"; import { createRequire } from "node:module"; import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Walk from dist/__tests__/ up to the agenticros package root (workspace), // regardless of whether the test is run from the workspace or from a deploy. // dist/__tests__/foo.test.js -> dist/__tests__ -> dist -> packages/agenticros const PKG_ROOT = join(__dirname, "..", ".."); const PLUGIN_ENTRY = join(PKG_ROOT, "dist", "index.js"); const MANIFEST = join(PKG_ROOT, "openclaw.plugin.json"); interface CapturedTool { name: string; execute?: (toolCallId: string, params: Record) => Promise; } function makeStubApi(): { api: Record; captured: { tools: CapturedTool[]; httpRoutes: unknown[]; commands: unknown[]; contexts: unknown[]; hooks: unknown[]; events: Record; }; } { const captured = { tools: [] as CapturedTool[], httpRoutes: [] as unknown[], commands: [] as unknown[], contexts: [] as unknown[], hooks: [] as unknown[], events: {} as Record, }; const logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, }; return { captured, api: { pluginConfig: {}, logger, registerTool: (def: CapturedTool) => captured.tools.push(def), registerHook: (h: unknown) => captured.hooks.push(h), registerCommand: (c: unknown) => captured.commands.push(c), registerContext: (c: unknown) => captured.contexts.push(c), registerHttpRoute: (r: unknown) => captured.httpRoutes.push(r), registerService: () => {}, on: (eventName: string, handler: unknown) => { (captured.events[eventName] ??= []).push(handler); }, off: () => {}, emit: () => {}, }, }; } interface PluginShape { id: string; name: string; register: (api: Record) => void | Promise; } function loadPlugin(): PluginShape { const req = createRequire(import.meta.url); const mod = req(PLUGIN_ENTRY) as { default?: PluginShape } & PluginShape; const plugin = mod.default ?? mod; return plugin; } test("plugin: register() is synchronous and produces the base 15-tool surface", () => { const plugin = loadPlugin(); assert.equal(plugin.id, "agenticros"); assert.equal(plugin.name, "AgenticROS"); assert.equal(typeof plugin.register, "function"); const { api, captured } = makeStubApi(); const result = plugin.register(api); // register() must return undefined (not a Promise) so OpenClaw's // captured.tools snapshot includes everything. assert.equal(result, undefined, "register() must be synchronous (returning Promise drops tools from OpenClaw snapshot)"); const toolNames = captured.tools.map((t) => t.name); const expected = [ "ros2_publish", "ros2_subscribe_once", "ros2_service_call", "ros2_action_goal", "ros2_param_get", "ros2_param_set", "ros2_list_topics", "ros2_camera_snapshot", "ros2_depth_distance", "ros2_list_capabilities", "ros2_list_robots", "ros2_discover_robots", "ros2_find_robots_for", "run_mission", // Phase 1.f — cancel an in-flight mission by id. "mission_cancel", "mission_pause", "mission_resume", ]; for (const name of expected) { assert.ok(toolNames.includes(name), `Expected base tool ${name} in registered set`); } }); test("plugin: ros2_list_capabilities is registered with an execute() function", () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "ros2_list_capabilities"); assert.ok(tool, "ros2_list_capabilities should be registered"); assert.equal(typeof tool.execute, "function", "registered tool must expose execute()"); }); test("plugin: ros2_list_capabilities.execute() returns the 6 intrinsic verbs", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "ros2_list_capabilities"); assert.ok(tool?.execute); const result = (await tool.execute("test-call", {})) as { content: Array<{ type: string; text: string }>; details: Record; }; assert.ok(result.content?.[0]?.text, "execute() must return text content"); const payload = JSON.parse(result.content[0].text) as { success: boolean; total: number; intrinsic_count: number; skill_count: number; capabilities: Array<{ id: string; verb: string; source?: { kind: string } }>; }; assert.equal(payload.success, true); assert.ok(payload.total >= 6, `total should be at least 6 (intrinsic floor), got ${payload.total}`); assert.equal(payload.intrinsic_count, 6, "should expose exactly 6 intrinsic verbs"); const intrinsicIds = payload.capabilities .filter((c) => c.source?.kind === "builtin") .map((c) => c.id) .sort(); assert.deepEqual(intrinsicIds, [ "drive_base", "list_topics", "measure_depth", "publish_topic", "subscribe_once", "take_snapshot", ]); // details payload should mirror the text payload (OpenClaw uses richer // results when the host requests them; should not disagree with text). assert.equal(payload.total, result.details.total); assert.equal(payload.intrinsic_count, result.details.intrinsic_count); }); test("manifest: contracts.tools allowlists ros2_list_capabilities", () => { const manifest = JSON.parse(readFileSync(MANIFEST, "utf-8")) as { contracts: { tools: string[] }; }; assert.ok( Array.isArray(manifest.contracts.tools), "manifest.contracts.tools must be an array", ); assert.ok( manifest.contracts.tools.includes("ros2_list_capabilities"), "ros2_list_capabilities must be in contracts.tools — otherwise OpenClaw tool profiles will filter it out", ); }); test("manifest: every registered base tool is also in contracts.tools", () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const registered = captured.tools.map((t) => t.name); const manifest = JSON.parse(readFileSync(MANIFEST, "utf-8")) as { contracts: { tools: string[] }; }; const allowlist = new Set(manifest.contracts.tools); for (const name of registered) { assert.ok( allowlist.has(name), `Tool ${name} is registered by the plugin but missing from contracts.tools — OpenClaw will silently drop it`, ); } }); test("plugin: ros2_list_capabilities with unknown robot_id returns a clean tool error (Phase 1.d)", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "ros2_list_capabilities"); assert.ok(tool?.execute); const result = (await tool.execute("test-call", { robot_id: "no-such-robot" })) as { content: Array<{ type: string; text: string }>; details: { success?: boolean; error?: string }; }; assert.equal(result.details.success, false, "unknown robot_id must surface success:false"); assert.ok(result.details.error?.includes("no-such-robot")); assert.ok( result.details.error?.toLowerCase().includes("ros2_list_robots"), "unknown robot_id error should recommend ros2_list_robots", ); }); test("plugin: ros2_publish exposes robot_id in its parameter schema (Phase 1.d)", () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "ros2_publish") as | (CapturedTool & { parameters?: { properties?: Record } }) | undefined; assert.ok(tool, "ros2_publish must be registered"); const props = tool.parameters?.properties ?? {}; assert.ok( Object.prototype.hasOwnProperty.call(props, "robot_id"), `ros2_publish should advertise robot_id in its TypeBox schema; got: ${Object.keys(props).join(", ")}`, ); }); test("plugin: ros2_find_robots_for is registered and execute() returns a ranked match list (Phase 1.e)", async () => { // Pins the new fleet-filter tool into the OpenClaw plugin's // synchronous register() snapshot. Executes it with an empty query // — that should return the whole fleet (legacy fallback = 1 robot) // with online=null on every match (no online filter was applied). const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "ros2_find_robots_for") as | (CapturedTool & { parameters?: { properties?: Record } }) | undefined; assert.ok(tool, "ros2_find_robots_for must be registered"); // All three filter axes need to appear in the TypeBox schema so the // agent can see them via tools/list. const props = tool!.parameters?.properties ?? {}; assert.ok(props["capability"], "must advertise 'capability' parameter"); assert.ok(props["kind"], "must advertise 'kind' parameter"); assert.ok(props["online"], "must advertise 'online' parameter"); const result = (await tool!.execute!("test-call", {})) as { content: Array<{ type: string; text: string }>; details: { success: boolean; total: number; query: Record; robots: Array<{ id: string; online: boolean | null; kind: string }>; }; }; assert.equal(result.details.success, true); assert.ok(result.details.total >= 1, "empty query should return >=1 robot from the fleet"); for (const r of result.details.robots) { assert.equal(r.online, null, "online must be null when 'online' filter is omitted"); } }); test("plugin: ros2_find_robots_for with bogus capability returns total=0 (no false positives)", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "ros2_find_robots_for"); assert.ok(tool?.execute); const result = (await tool.execute!("test-call", { capability: "no_such_verb_should_never_exist", })) as { details: { success: boolean; total: number } }; assert.equal(result.details.success, true); assert.equal(result.details.total, 0); }); test("manifest: contracts.tools allowlists ros2_find_robots_for", () => { // OpenClaw filters by the manifest allowlist; if we add a tool but // forget the contracts entry, the gateway silently drops it from the // model's tool list. The cascade we hit on 2026-06-10 motivated this // sibling test for every newly-added tool. const manifest = JSON.parse(readFileSync(MANIFEST, "utf-8")) as { contracts: { tools: string[] }; }; assert.ok(manifest.contracts.tools.includes("ros2_find_robots_for")); }); test("plugin: ros2_list_robots is registered and execute() returns the expected shape", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "ros2_list_robots"); assert.ok(tool?.execute, "ros2_list_robots should be registered with an execute()"); const result = (await tool.execute("test-call", {})) as { content: Array<{ type: string; text: string }>; details: { success: boolean; total: number; active_robot_id: string; robots: Array<{ id: string; name: string; source: string }>; }; }; assert.equal(result.details.success, true); assert.ok(result.details.total >= 1, "should report at least one robot (legacy fallback floor)"); assert.ok(typeof result.details.active_robot_id === "string" && result.details.active_robot_id.length > 0); const active = result.details.robots.find((r) => r.id === result.details.active_robot_id); assert.ok(active, "active_robot_id should refer to an entry in robots[]"); // Text payload should mirror details (every adapter returns the same shape). const parsed = JSON.parse(result.content[0].text) as typeof result.details; assert.equal(parsed.total, result.details.total); assert.equal(parsed.active_robot_id, result.details.active_robot_id); }); test("plugin: ros2_discover_robots is registered and listed in contracts.tools (Phase 1.d discovery)", () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "ros2_discover_robots"); assert.ok(tool, "ros2_discover_robots should be registered by the plugin"); assert.equal(typeof tool.execute, "function", "ros2_discover_robots must expose execute()"); const manifest = JSON.parse(readFileSync(MANIFEST, "utf-8")) as { contracts: { tools: string[] }; }; assert.ok( manifest.contracts.tools.includes("ros2_discover_robots"), "ros2_discover_robots must be in contracts.tools — otherwise OpenClaw tool profiles would silently filter it out", ); }); test("plugin: run_mission is registered with an execute() function", () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "run_mission"); assert.ok(tool, "run_mission should be registered"); assert.equal(typeof tool.execute, "function", "run_mission must expose execute()"); }); test("plugin: run_mission.execute() rejects malformed input cleanly", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "run_mission"); assert.ok(tool?.execute); // No mission arg at all — must return a structured error, never throw. const noArgs = (await tool.execute("test-call", {})) as { content: Array<{ type: string; text: string }>; details: { success?: boolean; error?: string }; }; assert.ok(noArgs.content?.[0]?.text); assert.equal(noArgs.details.success, false); // mission.steps not an array. const badSteps = (await tool.execute("test-call", { mission: { steps: "not-an-array" } })) as { content: Array<{ type: string; text: string }>; details: { success?: boolean; error?: string }; }; assert.equal(badSteps.details.success, false); }); test("plugin: run_mission.execute() succeeds with an empty steps array (no sub-dispatch)", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "run_mission"); assert.ok(tool?.execute); // Empty mission — no sub-tool calls happen, so no transport needed. const result = (await tool.execute("test-call", { mission: { name: "noop", steps: [] }, })) as { content: Array<{ type: string; text: string }>; details: { status: string; steps_run: number; steps_total: number; summary: string; }; }; assert.equal(result.details.status, "ok"); assert.equal(result.details.steps_run, 0); assert.equal(result.details.steps_total, 0); assert.ok(result.details.summary.includes("noop")); }); // --- Phase 1.f: mission_cancel surface + semantics --- // // These tests pin the contract that mission_cancel is in the OpenClaw // tool surface, behaves idempotently, and that run_mission echoes the // mission_id needed to invoke it. test("plugin: mission_cancel is registered with an execute() function (Phase 1.f)", () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "mission_cancel") as | (CapturedTool & { parameters?: { properties?: Record; required?: string[] } }) | undefined; assert.ok(tool, "mission_cancel must be registered alongside run_mission"); assert.equal(typeof tool.execute, "function", "mission_cancel must expose execute()"); const props = tool.parameters?.properties ?? {}; assert.ok(props["mission_id"], "must advertise 'mission_id' in its TypeBox schema"); assert.ok(props["reason"], "must advertise optional 'reason'"); }); test("manifest: contracts.tools allowlists mission_cancel (Phase 1.f)", () => { const manifest = JSON.parse(readFileSync(MANIFEST, "utf-8")) as { contracts: { tools: string[] }; }; assert.ok( manifest.contracts.tools.includes("mission_cancel"), "mission_cancel must be in contracts.tools — otherwise OpenClaw filters it out", ); }); test("plugin: mission_cancel.execute() on unknown id returns found=false (no-op, no error)", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "mission_cancel"); assert.ok(tool?.execute); const result = (await tool.execute("test-call", { mission_id: "mn_unknown_xyz" })) as { content: Array<{ type: string; text: string }>; details: { success: boolean; mission_id: string; found: boolean; already_cancelled: boolean; reason: string | null; }; }; assert.equal(result.details.success, true); assert.equal(result.details.found, false); assert.equal(result.details.already_cancelled, false); assert.equal(result.details.mission_id, "mn_unknown_xyz"); assert.equal(result.details.reason, null); // Text payload mirrors details for adapter consistency. const parsed = JSON.parse(result.content[0].text) as typeof result.details; assert.equal(parsed.found, false); }); test("plugin: mission_cancel.execute() rejects empty / missing mission_id with success=false", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "mission_cancel"); assert.ok(tool?.execute); const empty = (await tool.execute("test-call", { mission_id: " " })) as { details: { success: boolean; error?: string }; }; assert.equal(empty.details.success, false); assert.ok(empty.details.error?.toLowerCase().includes("mission_id")); }); test("plugin: run_mission echoes a mission_id in details so mission_cancel can target it", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "run_mission"); assert.ok(tool?.execute); const result = (await tool.execute("test-call", { mission: { name: "yield mission_id", steps: [] }, })) as { details: { status: string; mission_id?: string }; }; assert.ok( typeof result.details.mission_id === "string" && result.details.mission_id.startsWith("mn_"), `run_mission.details must include a mission_id; got: ${JSON.stringify(result.details)}`, ); }); // --- Phase 1.g: run_mission { goal } natural-language compile path --- // // The planner is exercised end-to-end through the OpenClaw tool API — // confirms the adapter accepts `goal` as an alternative to `mission` // and that compile failures surface a clean { success: false, ... } // details payload (so the LLM's tool-result handler can self-correct). test("plugin: run_mission accepts `goal` and emits a compiled plan in details.planner (Phase 1.g)", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "run_mission"); assert.ok(tool?.execute); const result = (await tool.execute("call-1", { goal: "take a picture" })) as { details: { status: string; mission_id?: string; planner?: { compiled_from_goal: string; candidates: Array<{ capability_id: string }>; }; steps?: Array<{ capability: string }>; }; }; assert.equal(result.details.planner?.compiled_from_goal, "take a picture"); assert.equal(result.details.planner?.candidates[0].capability_id, "take_snapshot"); // The compiled step shape must round-trip into the details payload — // this is what the agent sees and uses to inspect the run. assert.ok((result.details.steps ?? []).length >= 1); assert.equal(result.details.steps?.[0].capability, "take_snapshot"); assert.ok( typeof result.details.mission_id === "string" && result.details.mission_id.startsWith("mn_"), ); }); test("plugin: run_mission { goal } compound 'find … and drive toward it' compiles to 2 steps (Phase 1.g)", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "run_mission"); assert.ok(tool?.execute); const result = (await tool.execute("call-2", { goal: "find a chair and drive toward it", })) as { details: { planner?: { candidates: Array<{ capability_id: string }> }; steps_total?: number; steps?: Array<{ id: string; capability: string }>; }; }; // The planner ONLY emits find_object when the skill is in the // registry. The test plugin runs against the built-in registry // only (no fixture skill), so find_object should NOT be emitted — // confirming the planner's "no fabricated calls" guarantee. // We just assert the response is structured (no crash, status set). assert.ok(result.details, "details payload must be present"); }); test("plugin: run_mission { goal: 'paint the wall' } surfaces a compile error in details (Phase 1.g)", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "run_mission"); assert.ok(tool?.execute); const result = (await tool.execute("call-3", { goal: "paint the wall blue" })) as { details: { success: boolean; error?: string; suggestions?: string[] }; }; assert.equal(result.details.success, false); assert.ok(result.details.error); assert.ok(Array.isArray(result.details.suggestions) && result.details.suggestions.length > 0); }); test("plugin: run_mission with NEITHER mission nor goal returns a clear error (Phase 1.g)", async () => { const { api, captured } = makeStubApi(); loadPlugin().register(api); const tool = captured.tools.find((t) => t.name === "run_mission"); assert.ok(tool?.execute); const result = (await tool.execute("call-4", {})) as { details: { success: boolean; error?: string }; }; assert.equal(result.details.success, false); assert.ok(result.details.error?.toLowerCase().includes("mission")); assert.ok(result.details.error?.toLowerCase().includes("goal")); });