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,105 @@
1
+ /**
2
+ * Topic-utils tests — focus on the Phase 1.d dual signature where every
3
+ * helper accepts either the full config (legacy single-robot path) or a
4
+ * bare namespace string (per-robot routing). The string form is what
5
+ * the per-tool `robot_id` path uses, so it has to match the config form
6
+ * exactly for the same namespace value — that's the contract these
7
+ * tests pin down.
8
+ *
9
+ * The pre-Phase-1.d behaviour (config arg) is implicitly preserved
10
+ * because the new string-arg form delegates through the same internal
11
+ * logic; the equivalence tests catch any future drift.
12
+ */
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+
16
+ import {
17
+ toNamespacedTopic,
18
+ toNamespacedTopicFull,
19
+ resolveCameraSubscribeTopic,
20
+ } from "../topic-utils.js";
21
+ import { parseConfig } from "../config.js";
22
+
23
+ const NS = "robot-alpha";
24
+
25
+ test("topic-utils: toNamespacedTopic(string, '/cmd_vel') prefixes the namespace", () => {
26
+ assert.equal(toNamespacedTopic(NS, "/cmd_vel"), "/robot-alpha/cmd_vel");
27
+ });
28
+
29
+ test("topic-utils: toNamespacedTopic('', topic) is a no-op", () => {
30
+ assert.equal(toNamespacedTopic("", "/cmd_vel"), "/cmd_vel");
31
+ });
32
+
33
+ test("topic-utils: toNamespacedTopic trims namespace whitespace", () => {
34
+ assert.equal(toNamespacedTopic(" robot-alpha ", "/cmd_vel"), "/robot-alpha/cmd_vel");
35
+ });
36
+
37
+ test("topic-utils: toNamespacedTopic only prefixes root-level topics", () => {
38
+ // Multi-segment paths must be left alone (the agent is being explicit).
39
+ assert.equal(toNamespacedTopic(NS, "/some/multi/segment/path"), "/some/multi/segment/path");
40
+ // Already-prefixed paths must be left alone.
41
+ assert.equal(toNamespacedTopic(NS, "/robot-alpha/odom"), "/robot-alpha/odom");
42
+ });
43
+
44
+ test("topic-utils: toNamespacedTopicFull prefixes multi-segment topics too", () => {
45
+ assert.equal(
46
+ toNamespacedTopicFull(NS, "/camera/camera/color/image_raw/compressed"),
47
+ "/robot-alpha/camera/camera/color/image_raw/compressed",
48
+ );
49
+ });
50
+
51
+ test("topic-utils: toNamespacedTopicFull leaves already-prefixed topics alone", () => {
52
+ assert.equal(
53
+ toNamespacedTopicFull(NS, "/robot-alpha/odom"),
54
+ "/robot-alpha/odom",
55
+ );
56
+ });
57
+
58
+ test("topic-utils: resolveCameraSubscribeTopic keeps /camera/* unprefixed (global sensor convention)", () => {
59
+ assert.equal(
60
+ resolveCameraSubscribeTopic(NS, "/camera/camera/color/image_raw"),
61
+ "/camera/camera/color/image_raw",
62
+ );
63
+ assert.equal(
64
+ resolveCameraSubscribeTopic(NS, "/zed/zed/rgb/image"),
65
+ "/zed/zed/rgb/image",
66
+ );
67
+ });
68
+
69
+ test("topic-utils: resolveCameraSubscribeTopic does prefix non-sensor multi-segment paths", () => {
70
+ assert.equal(
71
+ resolveCameraSubscribeTopic(NS, "/some/other/topic"),
72
+ "/robot-alpha/some/other/topic",
73
+ );
74
+ });
75
+
76
+ test("topic-utils: string-arg and config-arg produce identical output for the same namespace", () => {
77
+ // This is the equivalence guarantee that per-robot routing relies on:
78
+ // toNamespacedTopic(config, t) === toNamespacedTopic(config.robot.namespace, t)
79
+ const cfg = parseConfig({ robot: { namespace: NS } });
80
+ const samples = [
81
+ "/cmd_vel",
82
+ "/odom",
83
+ "/robot-alpha/cmd_vel",
84
+ "/camera/camera/color/image_raw",
85
+ "/zed/depth",
86
+ "/some/multi/segment/path",
87
+ ];
88
+ for (const t of samples) {
89
+ assert.equal(
90
+ toNamespacedTopic(cfg, t),
91
+ toNamespacedTopic(NS, t),
92
+ `toNamespacedTopic drift on ${t}`,
93
+ );
94
+ assert.equal(
95
+ toNamespacedTopicFull(cfg, t),
96
+ toNamespacedTopicFull(NS, t),
97
+ `toNamespacedTopicFull drift on ${t}`,
98
+ );
99
+ assert.equal(
100
+ resolveCameraSubscribeTopic(cfg, t),
101
+ resolveCameraSubscribeTopic(NS, t),
102
+ `resolveCameraSubscribeTopic drift on ${t}`,
103
+ );
104
+ }
105
+ });
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Unit tests for `TransportPool` — Phase 1.d-pool.
3
+ *
4
+ * The pool exists to make per-robot transport overrides work without
5
+ * regressing single-transport deployments. These tests pin down the
6
+ * five invariants that matter in production:
7
+ *
8
+ * 1. **Legacy / single-transport stays a singleton.** When no robot
9
+ * has a per-robot transport override, every acquire hands back
10
+ * the same `__global__` entry. That's the pre-pool behaviour.
11
+ *
12
+ * 2. **Per-robot overrides build distinct transports.** Two robots
13
+ * with their own overrides get two distinct instances, keyed by
14
+ * `robot.id`. Cross-traffic between them is impossible.
15
+ *
16
+ * 3. **Acquire is lazy.** The factory is NOT invoked until the first
17
+ * acquire — building the pool itself doesn't open connections.
18
+ *
19
+ * 4. **Concurrent acquires deduplicate.** Two near-simultaneous
20
+ * first-acquires on the same key share one connect promise
21
+ * instead of double-connecting.
22
+ *
23
+ * 5. **`disconnectAll` drains the pool.** After draining, the pool
24
+ * reports `size: 0` and a subsequent acquire is allowed to build
25
+ * a fresh transport.
26
+ *
27
+ * The tests inject a fake factory so they don't depend on Zenoh, DDS,
28
+ * or anything network-bound. Each fake transport tracks the count of
29
+ * `connect()` / `disconnect()` calls so we can assert exactly when
30
+ * each was invoked.
31
+ */
32
+
33
+ import { test } from "node:test";
34
+ import assert from "node:assert/strict";
35
+
36
+ import type {
37
+ AgenticROSConfig,
38
+ ResolvedRobot,
39
+ RosTransport,
40
+ TransportConfig,
41
+ } from "../index.js";
42
+ import { parseConfig } from "../index.js";
43
+
44
+ import { TransportPool } from "../transport-pool.js";
45
+
46
+ interface FakeTransport extends RosTransport {
47
+ /** What `mode` did this fake get built for — handy to assert per-robot routing. */
48
+ builtForMode: TransportConfig["mode"];
49
+ connectCount: number;
50
+ disconnectCount: number;
51
+ }
52
+
53
+ function makeFactory(): {
54
+ factory: (cfg: TransportConfig) => Promise<FakeTransport>;
55
+ built: FakeTransport[];
56
+ } {
57
+ const built: FakeTransport[] = [];
58
+ const factory = async (cfg: TransportConfig): Promise<FakeTransport> => {
59
+ let status: "connected" | "disconnected" | "connecting" = "disconnected";
60
+ const t: FakeTransport = {
61
+ builtForMode: cfg.mode,
62
+ connectCount: 0,
63
+ disconnectCount: 0,
64
+ getStatus: () => status,
65
+ connect: async () => {
66
+ status = "connected";
67
+ t.connectCount++;
68
+ },
69
+ disconnect: async () => {
70
+ status = "disconnected";
71
+ t.disconnectCount++;
72
+ },
73
+ // The pool never calls anything below — provide no-op stubs so
74
+ // TypeScript is happy without coupling these tests to the full
75
+ // RosTransport surface area.
76
+ onConnectionChange: () => () => {},
77
+ publish: async () => {},
78
+ advertise: async () => {},
79
+ subscribe: () => ({ unsubscribe: () => {} }),
80
+ callService: async () => ({ result: false }),
81
+ sendActionGoal: async () => ({ result: false }),
82
+ listTopics: async () => [],
83
+ listServices: async () => [],
84
+ listActions: async () => [],
85
+ } as unknown as FakeTransport;
86
+ built.push(t);
87
+ return t;
88
+ };
89
+ return { factory, built };
90
+ }
91
+
92
+ function makeRobot(id: string, namespace: string, source: "config" | "legacy" = "config"): ResolvedRobot {
93
+ return {
94
+ id,
95
+ name: id,
96
+ namespace,
97
+ cameraTopic: "",
98
+ kind: "amr",
99
+ sensors: { has_realsense: false, has_lidar: false, has_arm: false },
100
+ source,
101
+ };
102
+ }
103
+
104
+ function configWithRobots(robots: Array<Record<string, unknown>>, top?: Record<string, unknown>): AgenticROSConfig {
105
+ return parseConfig({
106
+ transport: { mode: "local" },
107
+ ...(top ?? {}),
108
+ robots,
109
+ });
110
+ }
111
+
112
+ test("transport-pool: building a pool does not invoke the factory (lazy by design)", async () => {
113
+ const { factory, built } = makeFactory();
114
+ new TransportPool(factory);
115
+ assert.equal(built.length, 0, "constructor must never create a transport");
116
+ });
117
+
118
+ test("transport-pool: legacy single-robot config uses the shared __global__ entry", async () => {
119
+ const cfg = parseConfig({
120
+ transport: { mode: "rosbridge" },
121
+ robot: { namespace: "legacy-ns" },
122
+ });
123
+ const { factory, built } = makeFactory();
124
+ const pool = new TransportPool(factory);
125
+
126
+ const robot = makeRobot("legacy-ns", "legacy-ns", "legacy");
127
+ const a = (await pool.acquire(cfg, robot)) as FakeTransport;
128
+ const b = (await pool.acquire(cfg, robot)) as FakeTransport;
129
+
130
+ assert.strictEqual(a, b, "two acquires on the same legacy robot must return the same instance");
131
+ assert.equal(built.length, 1, "factory must run exactly once for the shared __global__ entry");
132
+ assert.equal(pool.size, 1);
133
+ assert.deepEqual(pool.keys(), ["__global__"]);
134
+ assert.equal(a.connectCount, 1, "connect must fire exactly once on first acquire");
135
+ });
136
+
137
+ test("transport-pool: multi-robot config WITHOUT overrides still shares __global__", async () => {
138
+ // The contract the pool guarantees: opting into `robots[]` is NOT a
139
+ // breaking change for single-transport deployments. Every robot
140
+ // continues to share one connection until SOMEONE adds an override.
141
+ const cfg = configWithRobots([
142
+ { id: "alpha", namespace: "alpha" },
143
+ { id: "beta", namespace: "beta", default: true },
144
+ ]);
145
+ const { factory, built } = makeFactory();
146
+ const pool = new TransportPool(factory);
147
+
148
+ const a = await pool.acquire(cfg, makeRobot("alpha", "alpha"));
149
+ const b = await pool.acquire(cfg, makeRobot("beta", "beta"));
150
+
151
+ assert.strictEqual(a, b, "robots without overrides must share __global__");
152
+ assert.equal(built.length, 1);
153
+ assert.deepEqual(pool.keys(), ["__global__"]);
154
+ });
155
+
156
+ test("transport-pool: per-robot override materialises a distinct transport keyed by robot.id", async () => {
157
+ const cfg = configWithRobots(
158
+ [
159
+ { id: "sim", namespace: "sim_robot" },
160
+ {
161
+ id: "field",
162
+ namespace: "field_robot",
163
+ transport: { mode: "zenoh", zenoh: { routerEndpoint: "ws://farm:10000" } },
164
+ },
165
+ ],
166
+ { zenoh: { routerEndpoint: "ws://default:10000" } },
167
+ );
168
+ const { factory, built } = makeFactory();
169
+ const pool = new TransportPool(factory);
170
+
171
+ const sim = (await pool.acquire(cfg, makeRobot("sim", "sim_robot"))) as FakeTransport;
172
+ const field = (await pool.acquire(cfg, makeRobot("field", "field_robot"))) as FakeTransport;
173
+
174
+ assert.notStrictEqual(sim, field, "the override robot must NOT share the global transport");
175
+ assert.equal(built.length, 2, "factory should have built exactly two transports");
176
+ assert.equal(sim.builtForMode, "local", "non-override robot uses the global mode (local)");
177
+ assert.equal(field.builtForMode, "zenoh", "override robot uses its override mode (zenoh)");
178
+ assert.deepEqual(pool.keys().sort(), ["__global__", "field"].sort());
179
+ });
180
+
181
+ test("transport-pool: second acquire on an override robot hits the cache (no double-build)", async () => {
182
+ const cfg = configWithRobots([
183
+ {
184
+ id: "field",
185
+ namespace: "field_robot",
186
+ transport: { mode: "zenoh" },
187
+ },
188
+ ]);
189
+ const { factory, built } = makeFactory();
190
+ const pool = new TransportPool(factory);
191
+
192
+ const a = (await pool.acquire(cfg, makeRobot("field", "field_robot"))) as FakeTransport;
193
+ const b = (await pool.acquire(cfg, makeRobot("field", "field_robot"))) as FakeTransport;
194
+
195
+ assert.strictEqual(a, b);
196
+ assert.equal(built.length, 1);
197
+ assert.equal(a.connectCount, 1);
198
+ });
199
+
200
+ test("transport-pool: concurrent first-acquires on the same key share one in-flight connect", async () => {
201
+ // The fake factory delays its resolution so we can verify the second
202
+ // acquire latches onto the first instead of kicking off a duplicate.
203
+ const cfg = configWithRobots([
204
+ { id: "field", namespace: "field_robot", transport: { mode: "zenoh" } },
205
+ ]);
206
+ let resolve: (t: FakeTransport) => void = () => {};
207
+ let firstPromise: Promise<FakeTransport> | null = null;
208
+ let factoryCalls = 0;
209
+ const factory = (_cfg: TransportConfig): Promise<FakeTransport> => {
210
+ factoryCalls++;
211
+ if (!firstPromise) {
212
+ firstPromise = new Promise<FakeTransport>((r) => (resolve = r));
213
+ return firstPromise;
214
+ }
215
+ // If something tried to build twice, fail loudly.
216
+ return Promise.reject(new Error("factory should not be called twice"));
217
+ };
218
+ const pool = new TransportPool(factory);
219
+
220
+ const robot = makeRobot("field", "field_robot");
221
+ const aPromise = pool.acquire(cfg, robot);
222
+ const bPromise = pool.acquire(cfg, robot);
223
+
224
+ // Resolve the in-flight connect.
225
+ const fake: FakeTransport = {
226
+ builtForMode: "zenoh",
227
+ connectCount: 0,
228
+ disconnectCount: 0,
229
+ getStatus: () => "connected" as const,
230
+ connect: async () => {
231
+ fake.connectCount++;
232
+ },
233
+ disconnect: async () => {
234
+ fake.disconnectCount++;
235
+ },
236
+ } as unknown as FakeTransport;
237
+ resolve(fake);
238
+
239
+ const [a, b] = await Promise.all([aPromise, bPromise]);
240
+ assert.strictEqual(a, b, "concurrent acquires must hand back the same instance");
241
+ assert.equal(factoryCalls, 1, "factory must be invoked exactly once for in-flight dedupe");
242
+ });
243
+
244
+ test("transport-pool: disconnectAll drains every entry and lets the next acquire rebuild", async () => {
245
+ const cfg = configWithRobots(
246
+ [
247
+ { id: "sim", namespace: "sim_robot" },
248
+ {
249
+ id: "field",
250
+ namespace: "field_robot",
251
+ transport: { mode: "zenoh" },
252
+ },
253
+ ],
254
+ { zenoh: { routerEndpoint: "ws://default:10000" } },
255
+ );
256
+ const { factory, built } = makeFactory();
257
+ const pool = new TransportPool(factory);
258
+
259
+ await pool.acquire(cfg, makeRobot("sim", "sim_robot")); // __global__
260
+ await pool.acquire(cfg, makeRobot("field", "field_robot")); // field
261
+ assert.equal(pool.size, 2);
262
+
263
+ await pool.disconnectAll();
264
+ assert.equal(pool.size, 0, "disconnectAll must clear the pool");
265
+ for (const t of built) {
266
+ assert.equal(t.disconnectCount, 1, "every entry must have been disconnected once");
267
+ }
268
+
269
+ // A fresh acquire post-drain must rebuild — proving the pool isn't
270
+ // in a wedged state after shutdown.
271
+ await pool.acquire(cfg, makeRobot("sim", "sim_robot"));
272
+ assert.equal(built.length, 3, "rebuild after drain creates a new transport");
273
+ assert.deepEqual(pool.keys(), ["__global__"]);
274
+ });
275
+
276
+ test("transport-pool: a cached transport that has dropped to disconnected is replaced on next acquire", async () => {
277
+ // Self-healing — mirrors the prior `connect()` behaviour where a
278
+ // half-dead transport got rebuilt rather than reused.
279
+ const cfg = configWithRobots([{ id: "alpha", namespace: "alpha" }]);
280
+ const { factory, built } = makeFactory();
281
+ const pool = new TransportPool(factory);
282
+ const robot = makeRobot("alpha", "alpha");
283
+
284
+ const first = (await pool.acquire(cfg, robot)) as FakeTransport;
285
+ assert.equal(built.length, 1);
286
+
287
+ // Simulate the underlying socket going down.
288
+ await first.disconnect();
289
+ assert.equal(first.getStatus(), "disconnected");
290
+
291
+ const second = (await pool.acquire(cfg, robot)) as FakeTransport;
292
+ assert.equal(built.length, 2, "stale entry must be rebuilt, not handed back");
293
+ assert.notStrictEqual(first, second);
294
+ assert.equal(second.getStatus(), "connected");
295
+ });