@nanobpm/urban-agent-client 0.1.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.
@@ -0,0 +1,829 @@
1
+ import assert from "node:assert/strict";
2
+ import { mock, test } from "node:test";
3
+ import { AgenticClient, connectAgenticChannel } from "./client.ts";
4
+ import { encodeFrame } from "./protocol.ts";
5
+ import type { Frame, ServePayload } from "./protocol.ts";
6
+ import { fakeTransportFactory } from "./testkit.ts";
7
+ import type { TransportCloseInfo, TransportFactory, TransportHooks } from "./transport.ts";
8
+
9
+ function serveFrame(instance: string, tokens: string[]): Uint8Array {
10
+ const payload: ServePayload = { instance, tokens };
11
+ const frame: Frame = { lane: "control", family: "serve", seq: 0, payload };
12
+ return encodeFrame(frame);
13
+ }
14
+
15
+ function newClient(overrides: Partial<Parameters<typeof connectAgenticChannel>[0]> = {}) {
16
+ const t = fakeTransportFactory();
17
+ const client = new AgenticClient({
18
+ url: "ws://test/agentic",
19
+ instance: "worker-1",
20
+ transport: t.factory,
21
+ reconnect: { enabled: false },
22
+ serveTimeoutMs: 0,
23
+ ...overrides,
24
+ });
25
+ return { client, t };
26
+ }
27
+
28
+ test("REGISTER → SERVE resolves the register promise with the resolved tokens", async () => {
29
+ const { client, t } = newClient({ serveTimeoutMs: 1000 });
30
+ client.connect();
31
+ t.last().fireOpen();
32
+
33
+ const pending = client.register({ capability: { cognition: "high", weight: 3, family: "opus", host: "cli" } });
34
+
35
+ // The client sent a REGISTER frame carrying the capability (never a token).
36
+ const sent = t.last().sentFrames;
37
+ const register = sent.find((f) => f.family === "register");
38
+ assert.ok(register, "a register frame was sent");
39
+ assert.deepEqual(register?.payload, {
40
+ instance: "worker-1",
41
+ capability: { cognition: "high", weight: 3, family: "opus", host: "cli" },
42
+ });
43
+
44
+ // Hub answers with SERVE.
45
+ t.last().deliver(serveFrame("worker-1", ["planning.spar#red", "implementation.impl"]));
46
+ const { serve } = await pending;
47
+ assert.deepEqual(serve, ["planning.spar#red", "implementation.impl"]);
48
+ assert.deepEqual(client.serve, ["planning.spar#red", "implementation.impl"]);
49
+ client.close();
50
+ });
51
+
52
+ test("a SERVE addressed to a different instance is ignored", async () => {
53
+ const scheduled: Array<() => void> = [];
54
+ const { client, t } = newClient({ serveTimeoutMs: 100, schedule: (fn) => scheduled.push(fn) });
55
+ client.connect();
56
+ t.last().fireOpen();
57
+ const pending = client.register({ capability: { cognition: "high" } });
58
+ t.last().deliver(serveFrame("someone-else", ["planning.spar#red"]));
59
+ // Drive the serve-timeout deterministically rather than depending on a real
60
+ // (unref'd) timer, which Node's test runner may never fire before it drains
61
+ // the loop and cancels the awaited rejection.
62
+ assert.equal(scheduled.length, 1, "a serve-timeout was scheduled");
63
+ scheduled.forEach((fn) => fn());
64
+ await assert.rejects(pending, /SERVE not received/);
65
+ client.close();
66
+ });
67
+
68
+ test("heartbeat, relay and deregister emit correctly-shaped frames", () => {
69
+ const { client, t } = newClient();
70
+ client.connect();
71
+ t.last().fireOpen();
72
+
73
+ client.heartbeat();
74
+ client.relay("stdout", "hello");
75
+ client.relay("stdout", "world!");
76
+ client.deregister("done");
77
+
78
+ const frames = t.last().sentFrames;
79
+ const heartbeat = frames.find((f) => f.family === "heartbeat");
80
+ assert.deepEqual(heartbeat?.payload, { instance: "worker-1" });
81
+ assert.equal(heartbeat?.lane, "control");
82
+
83
+ const relays = frames.filter((f) => f.family === "relay");
84
+ assert.equal(relays.length, 2);
85
+ assert.equal(relays[0]?.lane, "bulk");
86
+ // Per-stream byte offset advances by the UTF-8 length of each chunk.
87
+ assert.deepEqual(relays[0]?.payload, { stream: "stdout", offset: 0, chunk: "hello" });
88
+ assert.deepEqual(relays[1]?.payload, { stream: "stdout", offset: 5, chunk: "world!" });
89
+
90
+ const dereg = frames.find((f) => f.family === "deregister");
91
+ assert.deepEqual(dereg?.payload, { instance: "worker-1", reason: "done" });
92
+ assert.ok(t.last().wasClosedLocally());
93
+ });
94
+
95
+ test("a transport factory that throws surfaces onError and leaves 'connecting' instead of wedging", () => {
96
+ const failure = new Error("no global WebSocket available");
97
+ const errors: Error[] = [];
98
+ const closes: number[] = [];
99
+ const client = new AgenticClient({
100
+ url: "ws://test/agentic",
101
+ instance: "worker-1",
102
+ reconnect: { enabled: false },
103
+ serveTimeoutMs: 0,
104
+ transport: () => {
105
+ throw failure;
106
+ },
107
+ });
108
+ client.onError((e) => errors.push(e));
109
+ client.onClose(() => closes.push(1));
110
+
111
+ client.connect();
112
+
113
+ // The synchronous factory failure must not escape connect(): it is surfaced as
114
+ // a non-fatal error and drives a close, so the client leaves "connecting"
115
+ // (reconnect disabled → idle) instead of wedging with no transport and no signal.
116
+ assert.deepEqual(errors, [failure], "the factory failure surfaced via onError");
117
+ assert.equal(closes.length, 1, "onClose fired once for the failed attempt");
118
+ assert.equal(client.connectionState, "idle", "client did not wedge in 'connecting'");
119
+ });
120
+
121
+
122
+ test("deregister while the channel is down sends no deregister frame (best-effort only when open)", () => {
123
+ const { client, t } = newClient();
124
+ client.connect(); // transport built but never opened
125
+
126
+ // Never fired open: the channel is down. A deregister must not enqueue an
127
+ // unsendable frame that close() would only drop — it is best-effort.
128
+ client.deregister("done");
129
+
130
+ const dereg = t.last().sentFrames.find((f) => f.family === "deregister");
131
+ assert.equal(dereg, undefined, "no deregister frame was sent while disconnected");
132
+ assert.equal(client.connectionState, "closed");
133
+ assert.equal(client.buffered, 0, "close() left nothing buffered");
134
+ });
135
+
136
+ test("close() releases the outbound buffer so a terminal client pins no backlog", () => {
137
+ const { client } = newClient({ capability: { cognition: "high" } });
138
+ client.connect(); // transport built but never opened
139
+
140
+ // Accumulate a backlog during the outage: buffered relay frames.
141
+ for (let i = 0; i < 4; i++) {
142
+ client.relay("stdout", `chunk-${i}`);
143
+ }
144
+ assert.ok(client.buffered > 0, "frames buffered while the channel is down");
145
+
146
+ client.close();
147
+
148
+ // Terminal close must not pin the outage backlog in memory forever — the ring
149
+ // (and the per-stream relay offsets) can never be drained again, so they are
150
+ // released.
151
+ assert.equal(client.buffered, 0, "close() cleared the outbound ring");
152
+ });
153
+
154
+ test("a closed client refuses every outbound-producing call instead of buffering frames that can never drain", async () => {
155
+ const errors: Error[] = [];
156
+ const { client } = newClient({ capability: { cognition: "high" } });
157
+ client.onError((e) => errors.push(e));
158
+ client.connect();
159
+ client.close();
160
+
161
+ // register() fails fast rather than creating a pending promise that never resolves.
162
+ await assert.rejects(client.register({ capability: { cognition: "high" } }), /closed client/);
163
+
164
+ // heartbeat/relay are refused (surfaced via onError) and buffer nothing.
165
+ client.heartbeat();
166
+ client.relay("stdout", "post-close");
167
+ assert.equal(client.buffered, 0, "a closed client buffers no frames");
168
+ assert.ok(
169
+ errors.some((e) => /cannot heartbeat on a closed client/.test(e.message)),
170
+ "heartbeat after close surfaces an error",
171
+ );
172
+ assert.ok(
173
+ errors.some((e) => /cannot relay on a closed client/.test(e.message)),
174
+ "relay after close surfaces an error",
175
+ );
176
+ });
177
+
178
+ test("relay offset tracks UTF-8 byte length per stream, independently", () => {
179
+ const { client, t } = newClient();
180
+ client.connect();
181
+ t.last().fireOpen();
182
+ client.relay("a", "é"); // 2 bytes UTF-8
183
+ client.relay("b", "x"); // separate stream, offset 0
184
+ client.relay("a", "z"); // offset now 2
185
+ // Astral char (U+1F600) is 4 UTF-8 bytes but 2 UTF-16 code units, so the
186
+ // offset must advance by the UTF-8 byte count (4), never the JS string length
187
+ // (2) — guards the byte-length helper against surrogate-pair miscounting.
188
+ client.relay("a", "😀"); // offset now 3
189
+ client.relay("a", "!"); // offset now 3 + 4 = 7
190
+ const relays = t.last().sentFrames.filter((f) => f.family === "relay");
191
+ assert.deepEqual(relays.map((f) => f.payload), [
192
+ { stream: "a", offset: 0, chunk: "é" },
193
+ { stream: "b", offset: 0, chunk: "x" },
194
+ { stream: "a", offset: 2, chunk: "z" },
195
+ { stream: "a", offset: 3, chunk: "😀" },
196
+ { stream: "a", offset: 7, chunk: "!" },
197
+ ]);
198
+ client.close();
199
+ });
200
+
201
+ test("a relay dropped by the QoS overflow policy does not advance the stream offset", () => {
202
+ // With a single ring slot, the register buffered while the hub is down (control
203
+ // lane) fills the ring. A relay (bulk) enqueued now is the least-important frame
204
+ // in play, so the overflow policy DROPS the incoming relay rather than evict the
205
+ // higher-priority register. A dropped relay must consume no offset space, or the
206
+ // hub-side resume-from-offset (S5) would see a phantom gap for bytes never sent.
207
+ const { client, t } = newClient({ bufferCapacity: 1 });
208
+ client.connect(); // transport built, hub still down
209
+ client.register({ capability: { cognition: "high" } }).catch(() => {}); // control frame fills the slot; rejected on close()
210
+ assert.equal(client.buffered, 1, "the buffered register occupies the single ring slot");
211
+
212
+ client.relay("s", "dropped"); // bulk into a control-full ring → dropped
213
+ assert.equal(client.buffered, 1, "the dropped relay never entered the ring");
214
+
215
+ t.last().fireOpen(); // hub up: register drains, freeing the ring
216
+ client.relay("s", "sent"); // first relay actually accepted for stream "s"
217
+ const relays = t.last().sentFrames.filter((f) => f.family === "relay");
218
+ assert.deepEqual(
219
+ relays.map((f) => f.payload),
220
+ [{ stream: "s", offset: 0, chunk: "sent" }],
221
+ "offset starts at 0 — the earlier dropped relay consumed no offset space",
222
+ );
223
+ client.close();
224
+ });
225
+
226
+ test("buffers while the hub is down and drains in QoS order on reconnect", () => {
227
+ const { client, t } = newClient({ reconnect: { enabled: false }, capability: { cognition: "high" } });
228
+ client.connect(); // transport built but not yet open
229
+
230
+ // Produce a bulk storm plus a heartbeat while the channel is closed.
231
+ for (let i = 0; i < 5; i++) {
232
+ client.relay("stdout", `chunk-${i}`);
233
+ }
234
+ client.heartbeat();
235
+ assert.equal(client.buffered, 6);
236
+ assert.equal(t.last().sent.length, 0, "nothing sent while closed");
237
+
238
+ // Channel comes up: everything drains.
239
+ t.last().fireOpen();
240
+ assert.equal(client.buffered, 0);
241
+
242
+ const families = t.last().sentFrames.map((f) => f.family);
243
+ // On open the client auto-re-registers (capability set); register + heartbeat
244
+ // (control) drain ahead of the buffered bulk relay storm.
245
+ assert.equal(families[0], "register");
246
+ assert.equal(families[1], "heartbeat");
247
+ assert.deepEqual(families.slice(2), ["relay", "relay", "relay", "relay", "relay"]);
248
+ });
249
+
250
+ test("survives a mid-stream drop: unsent frames stay buffered and drain on the next open", () => {
251
+ const { client, t } = newClient({ reconnect: { enabled: false } });
252
+ client.connect();
253
+ t.last().fireOpen();
254
+ client.relay("stdout", "a");
255
+ assert.equal(client.buffered, 0);
256
+
257
+ // Hub drops; produce more while down.
258
+ t.last().drop();
259
+ assert.equal(client.connected, false);
260
+ client.relay("stdout", "b");
261
+ client.relay("stdout", "c");
262
+ assert.equal(client.buffered, 2);
263
+
264
+ // Reconnect manually (auto-reconnect disabled in this test).
265
+ client.connect();
266
+ t.last().fireOpen();
267
+ assert.equal(client.buffered, 0);
268
+ const chunks = t.last().sentFrames.filter((f) => f.family === "relay").map((f) => f.payload);
269
+ assert.deepEqual(chunks, [
270
+ { stream: "stdout", offset: 1, chunk: "b" },
271
+ { stream: "stdout", offset: 2, chunk: "c" },
272
+ ]);
273
+ });
274
+
275
+ test("auto-reconnects with the injected scheduler and re-registers", () => {
276
+ const scheduled: Array<() => void> = [];
277
+ const { client, t } = newClient({
278
+ reconnect: { enabled: true, initialDelayMs: 10 },
279
+ capability: { cognition: "high" },
280
+ schedule: (fn) => scheduled.push(fn),
281
+ });
282
+ client.connect();
283
+ t.last().fireOpen();
284
+ const firstTransport = t.last();
285
+
286
+ firstTransport.drop();
287
+ assert.equal(scheduled.length, 1, "a reconnect was scheduled");
288
+
289
+ // Fire the scheduled reconnect: a new transport is built.
290
+ scheduled[0]?.();
291
+ assert.equal(t.transports.length, 2);
292
+ t.last().fireOpen();
293
+
294
+ // The reconnected channel re-announces presence.
295
+ assert.equal(t.last().sentFrames[0]?.family, "register");
296
+ client.close();
297
+ });
298
+
299
+ test("register while the hub is down buffers and resolves once it comes up", async () => {
300
+ const { client, t } = newClient({ reconnect: { enabled: false }, serveTimeoutMs: 1000 });
301
+ client.connect(); // not open
302
+
303
+ const pending = client.register({ capability: { cognition: "high" } });
304
+ assert.ok(client.buffered >= 1, "register frame is buffered while down");
305
+
306
+ t.last().fireOpen(); // drains the buffered register
307
+ t.last().deliver(serveFrame("worker-1", ["ci.gate"]));
308
+ const { serve } = await pending;
309
+ assert.deepEqual(serve, ["ci.gate"]);
310
+ client.close();
311
+ });
312
+
313
+ test("malformed inbound bytes never crash the client — surfaced via onError", () => {
314
+ const { client, t } = newClient();
315
+ client.connect();
316
+ t.last().fireOpen();
317
+ const errors: Error[] = [];
318
+ client.onError((e) => errors.push(e));
319
+
320
+ t.last().deliver(new Uint8Array([0x00, 0x01, 0x02])); // garbage, too short
321
+ t.last().deliver(new Uint8Array()); // empty
322
+ // The client is still alive and usable.
323
+ client.heartbeat();
324
+ assert.ok(errors.length >= 2);
325
+ assert.ok(t.last().sentFrames.some((f) => f.family === "heartbeat"));
326
+ client.close();
327
+ });
328
+
329
+ test("connectAgenticChannel returns an already-connecting client", () => {
330
+ const t = fakeTransportFactory();
331
+ const client = connectAgenticChannel({ url: "ws://test", transport: t.factory, reconnect: { enabled: false } });
332
+ assert.equal(t.transports.length, 1);
333
+ assert.equal(client.connectionState, "connecting");
334
+ client.close();
335
+ });
336
+
337
+ test("close stops the client and rejects an in-flight register", async () => {
338
+ const { client, t } = newClient({ serveTimeoutMs: 0 });
339
+ client.connect();
340
+ t.last().fireOpen();
341
+ const pending = client.register({ capability: { cognition: "high" } });
342
+ client.close();
343
+ await assert.rejects(pending, /client closed/);
344
+ assert.equal(client.connectionState, "closed");
345
+ });
346
+
347
+ test("a superseding register coalesces the buffered REGISTER — only the newest capability drains", async () => {
348
+ const { client, t } = newClient({ reconnect: { enabled: false }, serveTimeoutMs: 1000 });
349
+ client.connect(); // not open — registers buffer
350
+
351
+ const first = client.register({ capability: { cognition: "low" } });
352
+ const second = client.register({ capability: { cognition: "high" } });
353
+
354
+ // The stale REGISTER is dropped from the ring, not left to drain alongside the new one.
355
+ assert.equal(client.buffered, 1, "only one REGISTER is buffered after superseding");
356
+ await assert.rejects(first, /superseded/);
357
+
358
+ t.last().fireOpen();
359
+ const registers = t.last().sentFrames.filter((f) => f.family === "register");
360
+ assert.equal(registers.length, 1, "exactly one REGISTER drains on open");
361
+ assert.deepEqual(registers[0]?.payload, { instance: "worker-1", capability: { cognition: "high" } });
362
+
363
+ t.last().deliver(serveFrame("worker-1", ["ci.gate"]));
364
+ const { serve } = await second;
365
+ assert.deepEqual(serve, ["ci.gate"]);
366
+ client.close();
367
+ });
368
+
369
+ test("a throw-only transport (no onClose) still drives reconnect instead of wedging", () => {
370
+ const scheduled: Array<() => void> = [];
371
+ const { client, t } = newClient({
372
+ reconnect: { enabled: true, initialDelayMs: 10 },
373
+ schedule: (fn) => scheduled.push(fn),
374
+ });
375
+ client.connect();
376
+ t.last().fireOpen();
377
+
378
+ // The transport now fails every send WITHOUT firing onClose (contract-minimal).
379
+ t.last().throwOnSend = true;
380
+ const errors: Error[] = [];
381
+ client.onError((e) => errors.push(e));
382
+
383
+ client.relay("stdout", "boom"); // enqueue → pump → send throws
384
+
385
+ // The client must not sit "open" with a full buffer waiting for an onClose
386
+ // that never comes: it forces the disconnect and schedules a reconnect.
387
+ assert.equal(client.connected, false, "client left the open state on send failure");
388
+ assert.equal(scheduled.length, 1, "a reconnect was scheduled");
389
+ assert.ok(errors.some((e) => /send fail/.test(e.message)), "the send failure was surfaced");
390
+ assert.equal(client.buffered, 1, "the unsent frame stays buffered for the reconnect drain");
391
+
392
+ // Reconnect and confirm the buffered frame drains on the fresh channel.
393
+ scheduled[0]?.();
394
+ assert.equal(t.transports.length, 2);
395
+ t.last().fireOpen();
396
+ assert.equal(client.buffered, 0, "buffered frame drained after reconnect");
397
+ assert.ok(t.last().sentFrames.some((f) => f.family === "relay"));
398
+ client.close();
399
+ });
400
+
401
+ test("an invalid outbound register payload rejects the promise fast without buffering", async () => {
402
+ // An empty instance id fails the S0 register contract (bad-instance).
403
+ const { client, t } = newClient({ instance: "", serveTimeoutMs: 0 });
404
+ client.connect();
405
+ t.last().fireOpen();
406
+
407
+ await assert.rejects(
408
+ client.register({ capability: { cognition: "high" } }),
409
+ /register payload failed validation/,
410
+ );
411
+ // The unsendable frame was never buffered, and none was sent.
412
+ assert.equal(client.buffered, 0);
413
+ assert.equal(t.last().sentFrames.filter((f) => f.family === "register").length, 0);
414
+ client.close();
415
+ });
416
+
417
+ test("an invalid outbound relay payload is dropped with onError, not buffered", () => {
418
+ const { client, t } = newClient({ reconnect: { enabled: false } });
419
+ client.connect(); // not open
420
+
421
+ const errors: Error[] = [];
422
+ client.onError((e) => errors.push(e));
423
+ client.relay("", "data"); // empty stream fails the S0 relay contract
424
+
425
+ assert.equal(client.buffered, 0, "the invalid relay was not buffered");
426
+ assert.ok(errors.some((e) => /relay payload failed validation/.test(e.message)));
427
+ client.close();
428
+ });
429
+
430
+ test("a rejected relay consumes no offset space (advance-only-on-accept)", () => {
431
+ const { client, t } = newClient({ reconnect: { enabled: false } });
432
+ client.connect();
433
+ t.last().fireOpen();
434
+
435
+ const errors: Error[] = [];
436
+ client.onError((e) => errors.push(e));
437
+
438
+ // An empty stream fails the S0 relay contract, so the frame is rejected at
439
+ // enqueue time. The offset for a real stream must be untouched by it, and a
440
+ // rejected relay must never advance its own (empty-stream) offset bucket.
441
+ client.relay("stdout", "ok"); // accepted → stdout offset advances to 2
442
+ client.relay("", "dropped"); // rejected: empty stream
443
+ client.relay("stdout", "next"); // must resume at offset 2, unaffected by the reject
444
+
445
+ assert.ok(errors.some((e) => /relay payload failed validation/.test(e.message)));
446
+ const relays = t.last().sentFrames.filter((f) => f.family === "relay").map((f) => f.payload);
447
+ assert.deepEqual(
448
+ relays,
449
+ [
450
+ { stream: "stdout", offset: 0, chunk: "ok" },
451
+ { stream: "stdout", offset: 2, chunk: "next" },
452
+ ],
453
+ "the rejected relay neither advanced nor corrupted any offset",
454
+ );
455
+ client.close();
456
+ });
457
+
458
+ test("capability set in options starts the auto-heartbeat timer on open, without an explicit register()", () => {
459
+ mock.timers.enable({ apis: ["setInterval"] });
460
+ try {
461
+ const { client, t } = newClient({
462
+ capability: { cognition: "high" },
463
+ heartbeatIntervalMs: 1000,
464
+ });
465
+ client.connect();
466
+ // Open auto-registers (capability was set in options); the documented
467
+ // auto-heartbeat must start here too, even though register() is never called.
468
+ t.last().fireOpen();
469
+
470
+ mock.timers.tick(1000);
471
+
472
+ const families = t.last().sentFrames.map((f) => f.family);
473
+ assert.ok(
474
+ families.includes("heartbeat"),
475
+ "auto-heartbeat fired for an auto-registered client without an explicit register()",
476
+ );
477
+ client.close();
478
+ } finally {
479
+ mock.timers.reset();
480
+ }
481
+ });
482
+
483
+ test("close notifies onClose subscribers even when the transport's close is silent/async", () => {
484
+ const { client, t } = newClient();
485
+ client.connect();
486
+ t.last().fireOpen();
487
+ // Model a real WebSocket: close() does not synchronously surface onClose.
488
+ t.last().silentClose = true;
489
+
490
+ const closes: Array<{ local?: boolean }> = [];
491
+ client.onClose((info) => closes.push(info));
492
+
493
+ client.close();
494
+
495
+ assert.equal(closes.length, 1, "onClose fired exactly once on caller-initiated close");
496
+ assert.equal(closes[0]?.local, true, "the close is reported as local (caller-initiated)");
497
+ assert.equal(client.connectionState, "closed");
498
+ });
499
+
500
+ test("close emits onClose exactly once even when the transport also fires its own onClose", () => {
501
+ const { client, t } = newClient();
502
+ client.connect();
503
+ t.last().fireOpen();
504
+ // FakeTransport.close() DOES fire onClose synchronously; the client also drives
505
+ // handleClose itself. The idempotency guard must collapse these to one emit.
506
+ const closes: Array<{ local?: boolean }> = [];
507
+ client.onClose((info) => closes.push(info));
508
+
509
+ client.close();
510
+
511
+ assert.equal(closes.length, 1, "exactly one close emitted despite two close signals");
512
+ assert.equal(client.connectionState, "closed");
513
+ });
514
+
515
+ test("close notifies onClose even when the client never reached open (caller close while connecting)", () => {
516
+ const { client } = newClient();
517
+ client.connect(); // connecting — never fireOpen
518
+ assert.equal(client.connectionState, "connecting");
519
+
520
+ const closes: Array<{ local?: boolean }> = [];
521
+ client.onClose((info) => closes.push(info));
522
+
523
+ client.close();
524
+
525
+ // A caller-initiated close while still connecting must surface onClose, just
526
+ // like a remote drop while connecting already does — no silent shutdowns.
527
+ assert.equal(closes.length, 1, "onClose fired once even though the channel never opened");
528
+ assert.equal(closes[0]?.local, true, "reported as a local (caller-initiated) close");
529
+ assert.equal(client.connectionState, "closed");
530
+ });
531
+
532
+ test("connect() is a no-op after close() — a shut-down client never reopens", () => {
533
+ const { client, t } = newClient();
534
+ client.connect();
535
+ t.last().fireOpen();
536
+ client.close();
537
+ assert.equal(client.connectionState, "closed");
538
+ assert.equal(t.transports.length, 1);
539
+
540
+ client.connect(); // must NOT reopen a terminally-closed client
541
+
542
+ assert.equal(client.connectionState, "closed", "still closed after a post-close connect()");
543
+ assert.equal(t.transports.length, 1, "no new transport was built after close()");
544
+ });
545
+
546
+ test("fakeTransportFactory().last() throws a clear error before any transport is created", () => {
547
+ const t = fakeTransportFactory();
548
+ // The type signature promises a FakeTransport; returning undefined here would be
549
+ // a misleading runtime crash downstream, so last() must fail loudly and early.
550
+ assert.throws(() => t.last(), /before any transport was created/);
551
+ });
552
+
553
+ test("on open, a REGISTER buffered behind other control frames is coalesced to the front", () => {
554
+ const { client, t } = newClient({ reconnect: { enabled: false }, capability: { cognition: "high" } });
555
+ client.connect(); // not open — control frames buffer
556
+
557
+ client.heartbeat(); // control lane, buffered first
558
+ // A register queued while down lands behind the heartbeat in the control lane.
559
+ client.register({ capability: { cognition: "high" } }).catch(() => {});
560
+ assert.equal(client.buffered, 2);
561
+
562
+ t.last().fireOpen();
563
+
564
+ const control = t.last().sentFrames.filter((f) => f.lane === "control").map((f) => f.family);
565
+ assert.equal(control[0], "register", "REGISTER drains ahead of the buffered heartbeat");
566
+ assert.equal(
567
+ control.filter((f) => f === "register").length,
568
+ 1,
569
+ "exactly one REGISTER drains (the buffered one was coalesced, not duplicated)",
570
+ );
571
+ client.close();
572
+ });
573
+
574
+ test("a send failure reports a remote (non-local) close even when transport.close() fires its own onClose", () => {
575
+ const { client, t } = newClient({ reconnect: { enabled: false } });
576
+ const closes: TransportCloseInfo[] = [];
577
+ client.onClose((info) => closes.push(info));
578
+ client.connect();
579
+ t.last().fireOpen();
580
+
581
+ // Arm a send failure. When forceReconnect tears the transport down, the
582
+ // FakeTransport's close() synchronously fires its own onClose({ local: true }).
583
+ // The client must still report this send failure as a REMOTE drop, not a
584
+ // caller-initiated (local) close, so onClose subscribers aren't misled.
585
+ t.last().throwOnSend = true;
586
+ client.relay("stdout", "boom"); // pump → send throws → forceReconnect({ local: false })
587
+
588
+ assert.equal(closes.length, 1, "exactly one close is reported");
589
+ assert.equal(closes[0]?.local, false, "a send failure is a remote drop, not a local close");
590
+ client.close();
591
+ });
592
+
593
+ test("auto-heartbeats coalesce while down so a long outage can't shed buffered relay", () => {
594
+ // A bounded ring: control-lane heartbeats are never evicted, so without
595
+ // coalescing an outage's worth of heartbeat ticks would pile up and shed the
596
+ // buffered bulk relay (worker output) via the QoS overflow policy.
597
+ const { client, t } = newClient({
598
+ reconnect: { enabled: false },
599
+ capability: { cognition: "high" },
600
+ bufferCapacity: 3,
601
+ });
602
+ client.connect(); // connecting, not yet open
603
+
604
+ client.relay("stdout", "work"); // one bulk relay buffered
605
+ for (let i = 0; i < 10; i++) {
606
+ client.heartbeat(); // a long outage's worth of heartbeat ticks
607
+ }
608
+ assert.equal(client.buffered, 2, "at most one heartbeat is buffered regardless of tick count");
609
+
610
+ t.last().fireOpen();
611
+ const families = t.last().sentFrames.map((f) => f.family);
612
+ assert.deepEqual(
613
+ families,
614
+ ["register", "heartbeat", "relay"],
615
+ "the buffered relay survived and drains after the heartbeats coalesced",
616
+ );
617
+ client.close();
618
+ });
619
+
620
+ test("the default reconnect scheduler unrefs its backoff timer so it can't pin the event loop open", () => {
621
+ // Spy on the shared Timeout prototype's unref so we observe the DEFAULT
622
+ // scheduler (no `schedule` override) unref its backoff timer, just like the
623
+ // serve-timeout and heartbeat timers do.
624
+ const probe = setTimeout(() => {}, 0);
625
+ const timeoutProto = Object.getPrototypeOf(probe);
626
+ clearTimeout(probe);
627
+ const unrefSpy = mock.method(timeoutProto, "unref");
628
+ try {
629
+ const t = fakeTransportFactory();
630
+ const client = new AgenticClient({
631
+ url: "ws://test/agentic",
632
+ instance: "worker-1",
633
+ transport: t.factory,
634
+ reconnect: { enabled: true, initialDelayMs: 10 },
635
+ });
636
+ client.connect();
637
+ t.last().fireOpen();
638
+
639
+ unrefSpy.mock.resetCalls();
640
+ t.last().drop(); // remote drop → default scheduler schedules a reconnect
641
+ assert.ok(unrefSpy.mock.callCount() >= 1, "the default reconnect backoff timer was unref'd");
642
+ client.close();
643
+ } finally {
644
+ unrefSpy.mock.restore();
645
+ }
646
+ });
647
+
648
+ test("close() releases the outbound buffer BEFORE it emits onClose, so subscribers see a self-consistent terminal state", () => {
649
+ const { client } = newClient({ capability: { cognition: "high" } });
650
+ client.connect(); // transport built but never opened
651
+
652
+ // Accumulate an outage backlog: buffered relay frames + per-stream offsets.
653
+ for (let i = 0; i < 4; i++) {
654
+ client.relay("stdout", `chunk-${i}`);
655
+ }
656
+ assert.ok(client.buffered > 0, "frames buffered while the channel is down");
657
+
658
+ // An onClose subscriber must observe the released buffers close() documents,
659
+ // not a stale non-zero backlog. Capture what `buffered` reads at emit time.
660
+ let bufferedAtClose = -1;
661
+ client.onClose(() => {
662
+ bufferedAtClose = client.buffered;
663
+ });
664
+
665
+ client.close();
666
+
667
+ assert.equal(bufferedAtClose, 0, "onClose observed a released, self-consistent outbound buffer");
668
+ assert.equal(client.buffered, 0, "close() cleared the outbound ring");
669
+ });
670
+
671
+ test("close() releases the outbound buffer BEFORE the transport's synchronous onClose can surface it", () => {
672
+ // A transport seam is injectable and may legally fire onClose *synchronously*
673
+ // from close() (as FakeTransport does when open). This one always does so,
674
+ // even while the client still holds a backlog — the exact window round 11's
675
+ // handleClose reorder did NOT cover, because that path runs AFTER
676
+ // transport.close(). An onClose subscriber must still observe the released,
677
+ // self-consistent terminal state, not a stale non-zero backlog.
678
+ let hooks: TransportHooks | undefined;
679
+ const syncCloseTransport: TransportFactory = (_url, h) => {
680
+ hooks = h;
681
+ return {
682
+ send() {
683
+ throw new Error("never open: force the client to buffer");
684
+ },
685
+ close() {
686
+ // Fire onClose synchronously from within close(), like an open WebSocket
687
+ // that resolves its close on the same tick would be free to do.
688
+ h.onClose({ local: true });
689
+ },
690
+ };
691
+ };
692
+
693
+ const client = new AgenticClient({
694
+ url: "ws://test/agentic",
695
+ instance: "worker-1",
696
+ transport: syncCloseTransport,
697
+ reconnect: { enabled: false },
698
+ serveTimeoutMs: 0,
699
+ capability: { cognition: "high" },
700
+ });
701
+ client.connect(); // builds the transport; it never opens, so frames buffer
702
+ assert.ok(hooks !== undefined, "transport factory was invoked on connect()");
703
+
704
+ for (let i = 0; i < 4; i++) {
705
+ client.relay("stdout", `chunk-${i}`);
706
+ }
707
+ assert.ok(client.buffered > 0, "frames buffered while the channel is down");
708
+
709
+ let bufferedAtClose = -1;
710
+ client.onClose(() => {
711
+ if (bufferedAtClose === -1) {
712
+ bufferedAtClose = client.buffered; // capture the FIRST close the subscriber sees
713
+ }
714
+ });
715
+
716
+ client.close(); // transport.close() fires onClose synchronously, before handleClose
717
+
718
+ assert.equal(
719
+ bufferedAtClose,
720
+ 0,
721
+ "the transport's synchronous onClose observed a released outbound buffer",
722
+ );
723
+ assert.equal(client.buffered, 0, "close() cleared the outbound ring");
724
+ });
725
+
726
+ test("construction rejects timing/backoff options Node would coerce into a 0ms hot loop", () => {
727
+ // Node's setTimeout/setInterval treat a negative or NaN delay as 0, which would
728
+ // turn a misconfigured heartbeat or reconnect backoff into an event-loop-saturating
729
+ // tight loop. The client must fail fast at construction — the same fail-fast
730
+ // contract OutboundRing enforces on capacity — rather than degrade silently.
731
+ const base = { url: "ws://test/agentic", transport: fakeTransportFactory().factory };
732
+
733
+ assert.throws(() => new AgenticClient({ ...base, heartbeatIntervalMs: -1 }), RangeError);
734
+ assert.throws(() => new AgenticClient({ ...base, heartbeatIntervalMs: Number.NaN }), RangeError);
735
+ assert.throws(() => new AgenticClient({ ...base, serveTimeoutMs: -5 }), RangeError);
736
+ assert.throws(() => new AgenticClient({ ...base, serveTimeoutMs: Number.NaN }), RangeError);
737
+ assert.throws(() => new AgenticClient({ ...base, reconnect: { initialDelayMs: -1 } }), RangeError);
738
+ assert.throws(() => new AgenticClient({ ...base, reconnect: { initialDelayMs: Number.NaN } }), RangeError);
739
+ assert.throws(() => new AgenticClient({ ...base, reconnect: { maxDelayMs: -1 } }), RangeError);
740
+ assert.throws(() => new AgenticClient({ ...base, reconnect: { maxDelayMs: Number.POSITIVE_INFINITY } }), RangeError);
741
+ // A backoff factor < 1 shrinks the delay toward 0 on every retry — also a hot loop.
742
+ assert.throws(() => new AgenticClient({ ...base, reconnect: { factor: 0.5 } }), RangeError);
743
+ assert.throws(() => new AgenticClient({ ...base, reconnect: { factor: Number.NaN } }), RangeError);
744
+ // Reconnect delays have no "disabled" sentinel (enabled:false disables reconnect),
745
+ // so 0ms is only ever a hot loop: 0 * factor stays 0, and a 0ms maxDelayMs clamps
746
+ // every backoff back to 0. Both must be rejected (>= 1), unlike heartbeat/serveTimeout.
747
+ assert.throws(() => new AgenticClient({ ...base, reconnect: { initialDelayMs: 0 } }), RangeError);
748
+ assert.throws(() => new AgenticClient({ ...base, reconnect: { maxDelayMs: 0 } }), RangeError);
749
+
750
+ // The disabling sentinels stay legal: heartbeat 0 (off) and serveTimeout 0 (no timeout).
751
+ assert.doesNotThrow(() => new AgenticClient({ ...base, heartbeatIntervalMs: 0, serveTimeoutMs: 0 }));
752
+ });
753
+
754
+ test("a throwing onError subscriber can't crash internal error handling or starve siblings", () => {
755
+ // emitError runs inside internal error handling (e.g. a malformed inbound
756
+ // frame). A subscriber that throws there must be contained: it must neither
757
+ // propagate out of the handler (which could take the worker down) nor stop
758
+ // sibling subscribers from receiving the error.
759
+ const { client, t } = newClient();
760
+ client.connect();
761
+ t.last().fireOpen();
762
+
763
+ const seen: Error[] = [];
764
+ client.onError(() => {
765
+ throw new Error("subscriber blew up");
766
+ });
767
+ client.onError((e) => seen.push(e));
768
+
769
+ // Deliver garbage: decode fails and the client calls emitError internally.
770
+ assert.doesNotThrow(() => t.last().deliver(new Uint8Array([0x00, 0x01, 0x02])));
771
+
772
+ // The well-behaved sibling still received the decode error despite the
773
+ // earlier subscriber throwing, and the client is still usable afterwards.
774
+ assert.equal(seen.length, 1);
775
+ client.heartbeat();
776
+ assert.ok(t.last().sentFrames.some((f) => f.family === "heartbeat"));
777
+ client.close();
778
+ });
779
+
780
+ test("a throwing non-error subscriber is contained and doesn't starve siblings", () => {
781
+ // The containment contract is uniform across every emit* fan-out, not just
782
+ // onError: one bad frame subscriber must not break dispatch to the rest.
783
+ const { client, t } = newClient({ serveTimeoutMs: 1000 });
784
+ client.connect();
785
+ t.last().fireOpen();
786
+
787
+ const frames: Frame[] = [];
788
+ client.onFrame(() => {
789
+ throw new Error("frame subscriber blew up");
790
+ });
791
+ client.onFrame((f) => frames.push(f));
792
+
793
+ assert.doesNotThrow(() => t.last().deliver(serveFrame("worker-1", ["planning.spar"])));
794
+ assert.equal(frames.length, 1);
795
+ client.close();
796
+ });
797
+
798
+ test("close() is terminal even when a prior send-failure already consumed the close guard", () => {
799
+ // A send failure routes through forceReconnect({ local: false }) → handleClose,
800
+ // which sets closeHandled = true and (with reconnect enabled) leaves the client
801
+ // in "connecting" while a reconnect is scheduled. If the caller then calls
802
+ // close() during that window, handleClose early-returns on the closeHandled
803
+ // guard — so close() must enforce the terminal "closed" state itself. Otherwise
804
+ // isClosed stays false, post-close calls could buffer frames again, and (since
805
+ // closedByCaller is now set) the scheduled reconnect skips openTransport, wedging
806
+ // the client in "connecting" forever.
807
+ const scheduled: Array<() => void> = [];
808
+ const { client, t } = newClient({
809
+ reconnect: { enabled: true, initialDelayMs: 10 },
810
+ schedule: (fn) => scheduled.push(fn),
811
+ });
812
+ client.connect();
813
+ t.last().fireOpen();
814
+
815
+ // Arm and trigger a send failure: forceReconnect drives handleClose, which
816
+ // consumes the closeHandled guard and schedules a reconnect (captured, unfired).
817
+ t.last().throwOnSend = true;
818
+ client.relay("stdout", "boom");
819
+ assert.equal(client.connectionState, "connecting", "send failure left the client reconnecting");
820
+ assert.equal(scheduled.length, 1, "a reconnect was scheduled");
821
+
822
+ // Now the caller closes during the reconnect window.
823
+ client.close();
824
+ assert.equal(client.connectionState, "closed", "close() enforces the terminal state");
825
+
826
+ // Firing the previously-scheduled reconnect must not resurrect the closed client.
827
+ scheduled[0]?.();
828
+ assert.equal(client.connectionState, "closed", "a scheduled reconnect can't reopen a closed client");
829
+ });