@moltzap/client 2026.414.0 → 2026.425.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.
Files changed (216) hide show
  1. package/dist/channel-core.d.ts +28 -15
  2. package/dist/channel-core.d.ts.map +1 -1
  3. package/dist/channel-core.js +113 -74
  4. package/dist/channel-core.js.map +1 -1
  5. package/dist/channel-core.test.js +126 -34
  6. package/dist/channel-core.test.js.map +1 -1
  7. package/dist/cli/__tests__/cli-multi-agent.int.test.d.ts +2 -0
  8. package/dist/cli/__tests__/cli-multi-agent.int.test.d.ts.map +1 -0
  9. package/dist/cli/__tests__/cli-multi-agent.int.test.js +26 -0
  10. package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -0
  11. package/dist/cli/commands/agents.d.ts +13 -2
  12. package/dist/cli/commands/agents.d.ts.map +1 -1
  13. package/dist/cli/commands/agents.js +30 -18
  14. package/dist/cli/commands/agents.js.map +1 -1
  15. package/dist/cli/commands/apps.d.ts +96 -0
  16. package/dist/cli/commands/apps.d.ts.map +1 -0
  17. package/dist/cli/commands/apps.js +193 -0
  18. package/dist/cli/commands/apps.js.map +1 -0
  19. package/dist/cli/commands/apps.test.d.ts +2 -0
  20. package/dist/cli/commands/apps.test.d.ts.map +1 -0
  21. package/dist/cli/commands/apps.test.js +220 -0
  22. package/dist/cli/commands/apps.test.js.map +1 -0
  23. package/dist/cli/commands/contacts.d.ts +18 -2
  24. package/dist/cli/commands/contacts.d.ts.map +1 -1
  25. package/dist/cli/commands/contacts.js +44 -52
  26. package/dist/cli/commands/contacts.js.map +1 -1
  27. package/dist/cli/commands/conversations-v2.test.d.ts +2 -0
  28. package/dist/cli/commands/conversations-v2.test.d.ts.map +1 -0
  29. package/dist/cli/commands/conversations-v2.test.js +92 -0
  30. package/dist/cli/commands/conversations-v2.test.js.map +1 -0
  31. package/dist/cli/commands/conversations.d.ts +73 -3
  32. package/dist/cli/commands/conversations.d.ts.map +1 -1
  33. package/dist/cli/commands/conversations.js +157 -108
  34. package/dist/cli/commands/conversations.js.map +1 -1
  35. package/dist/cli/commands/invite.d.ts +6 -2
  36. package/dist/cli/commands/invite.d.ts.map +1 -1
  37. package/dist/cli/commands/invite.js +8 -6
  38. package/dist/cli/commands/invite.js.map +1 -1
  39. package/dist/cli/commands/messages.d.ts +48 -0
  40. package/dist/cli/commands/messages.d.ts.map +1 -0
  41. package/dist/cli/commands/messages.js +68 -0
  42. package/dist/cli/commands/messages.js.map +1 -0
  43. package/dist/cli/commands/messages.test.d.ts +2 -0
  44. package/dist/cli/commands/messages.test.d.ts.map +1 -0
  45. package/dist/cli/commands/messages.test.js +73 -0
  46. package/dist/cli/commands/messages.test.js.map +1 -0
  47. package/dist/cli/commands/permissions.d.ts +66 -0
  48. package/dist/cli/commands/permissions.d.ts.map +1 -0
  49. package/dist/cli/commands/permissions.js +101 -0
  50. package/dist/cli/commands/permissions.js.map +1 -0
  51. package/dist/cli/commands/permissions.test.d.ts +2 -0
  52. package/dist/cli/commands/permissions.test.d.ts.map +1 -0
  53. package/dist/cli/commands/permissions.test.js +126 -0
  54. package/dist/cli/commands/permissions.test.js.map +1 -0
  55. package/dist/cli/commands/ping.d.ts +6 -2
  56. package/dist/cli/commands/ping.d.ts.map +1 -1
  57. package/dist/cli/commands/ping.js +21 -20
  58. package/dist/cli/commands/ping.js.map +1 -1
  59. package/dist/cli/commands/presence.d.ts +10 -2
  60. package/dist/cli/commands/presence.d.ts.map +1 -1
  61. package/dist/cli/commands/presence.js +27 -15
  62. package/dist/cli/commands/presence.js.map +1 -1
  63. package/dist/cli/commands/register.d.ts +24 -2
  64. package/dist/cli/commands/register.d.ts.map +1 -1
  65. package/dist/cli/commands/register.js +123 -47
  66. package/dist/cli/commands/register.js.map +1 -1
  67. package/dist/cli/commands/register.test.js +45 -27
  68. package/dist/cli/commands/register.test.js.map +1 -1
  69. package/dist/cli/commands/send.d.ts +43 -2
  70. package/dist/cli/commands/send.d.ts.map +1 -1
  71. package/dist/cli/commands/send.js +55 -13
  72. package/dist/cli/commands/send.js.map +1 -1
  73. package/dist/cli/commands/send.test.js +20 -19
  74. package/dist/cli/commands/send.test.js.map +1 -1
  75. package/dist/cli/commands/status.d.ts +6 -2
  76. package/dist/cli/commands/status.d.ts.map +1 -1
  77. package/dist/cli/commands/status.js +16 -10
  78. package/dist/cli/commands/status.js.map +1 -1
  79. package/dist/cli/commands/whoami.d.ts +7 -2
  80. package/dist/cli/commands/whoami.d.ts.map +1 -1
  81. package/dist/cli/commands/whoami.js +13 -7
  82. package/dist/cli/commands/whoami.js.map +1 -1
  83. package/dist/cli/config.d.ts +24 -8
  84. package/dist/cli/config.d.ts.map +1 -1
  85. package/dist/cli/config.js +65 -39
  86. package/dist/cli/config.js.map +1 -1
  87. package/dist/cli/config.test.js +12 -10
  88. package/dist/cli/config.test.js.map +1 -1
  89. package/dist/cli/http-client.d.ts +6 -2
  90. package/dist/cli/http-client.d.ts.map +1 -1
  91. package/dist/cli/http-client.js +25 -24
  92. package/dist/cli/http-client.js.map +1 -1
  93. package/dist/cli/index.d.ts +16 -1
  94. package/dist/cli/index.d.ts.map +1 -1
  95. package/dist/cli/index.js +160 -27
  96. package/dist/cli/index.js.map +1 -1
  97. package/dist/cli/profile.d.ts +134 -0
  98. package/dist/cli/profile.d.ts.map +1 -0
  99. package/dist/cli/profile.js +217 -0
  100. package/dist/cli/profile.js.map +1 -0
  101. package/dist/cli/profile.test.d.ts +2 -0
  102. package/dist/cli/profile.test.d.ts.map +1 -0
  103. package/dist/cli/profile.test.js +263 -0
  104. package/dist/cli/profile.test.js.map +1 -0
  105. package/dist/cli/runtime.d.ts +34 -0
  106. package/dist/cli/runtime.d.ts.map +1 -0
  107. package/dist/cli/runtime.js +94 -0
  108. package/dist/cli/runtime.js.map +1 -0
  109. package/dist/cli/socket-client.d.ts +15 -12
  110. package/dist/cli/socket-client.d.ts.map +1 -1
  111. package/dist/cli/socket-client.js +72 -75
  112. package/dist/cli/socket-client.js.map +1 -1
  113. package/dist/cli/transport.d.ts +181 -0
  114. package/dist/cli/transport.d.ts.map +1 -0
  115. package/dist/cli/transport.js +324 -0
  116. package/dist/cli/transport.js.map +1 -0
  117. package/dist/cli/transport.test.d.ts +2 -0
  118. package/dist/cli/transport.test.d.ts.map +1 -0
  119. package/dist/cli/transport.test.js +197 -0
  120. package/dist/cli/transport.test.js.map +1 -0
  121. package/dist/index.d.ts +6 -2
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +1 -0
  124. package/dist/index.js.map +1 -1
  125. package/dist/runtime/close-info.d.ts +94 -0
  126. package/dist/runtime/close-info.d.ts.map +1 -0
  127. package/dist/runtime/close-info.js +109 -0
  128. package/dist/runtime/close-info.js.map +1 -0
  129. package/dist/runtime/close-info.test.d.ts +2 -0
  130. package/dist/runtime/close-info.test.d.ts.map +1 -0
  131. package/dist/runtime/close-info.test.js +102 -0
  132. package/dist/runtime/close-info.test.js.map +1 -0
  133. package/dist/runtime/errors.d.ts +47 -0
  134. package/dist/runtime/errors.d.ts.map +1 -0
  135. package/dist/runtime/errors.js +20 -0
  136. package/dist/runtime/errors.js.map +1 -0
  137. package/dist/runtime/errors.test.d.ts +2 -0
  138. package/dist/runtime/errors.test.d.ts.map +1 -0
  139. package/dist/runtime/errors.test.js +66 -0
  140. package/dist/runtime/errors.test.js.map +1 -0
  141. package/dist/runtime/frame.d.ts +25 -0
  142. package/dist/runtime/frame.d.ts.map +1 -0
  143. package/dist/runtime/frame.js +106 -0
  144. package/dist/runtime/frame.js.map +1 -0
  145. package/dist/runtime/frame.test.d.ts +2 -0
  146. package/dist/runtime/frame.test.d.ts.map +1 -0
  147. package/dist/runtime/frame.test.js +32 -0
  148. package/dist/runtime/frame.test.js.map +1 -0
  149. package/dist/runtime/index.d.ts +3 -0
  150. package/dist/runtime/index.d.ts.map +1 -0
  151. package/dist/runtime/index.js +3 -0
  152. package/dist/runtime/index.js.map +1 -0
  153. package/dist/runtime/refs.d.ts +10 -0
  154. package/dist/runtime/refs.d.ts.map +1 -0
  155. package/dist/runtime/refs.js +10 -0
  156. package/dist/runtime/refs.js.map +1 -0
  157. package/dist/runtime/runtime.test.d.ts +2 -0
  158. package/dist/runtime/runtime.test.d.ts.map +1 -0
  159. package/dist/runtime/runtime.test.js +29 -0
  160. package/dist/runtime/runtime.test.js.map +1 -0
  161. package/dist/runtime/subscribers.d.ts +157 -0
  162. package/dist/runtime/subscribers.d.ts.map +1 -0
  163. package/dist/runtime/subscribers.js +131 -0
  164. package/dist/runtime/subscribers.js.map +1 -0
  165. package/dist/runtime/subscribers.test.d.ts +2 -0
  166. package/dist/runtime/subscribers.test.d.ts.map +1 -0
  167. package/dist/runtime/subscribers.test.js +155 -0
  168. package/dist/runtime/subscribers.test.js.map +1 -0
  169. package/dist/service.d.ts +68 -17
  170. package/dist/service.d.ts.map +1 -1
  171. package/dist/service.js +461 -307
  172. package/dist/service.js.map +1 -1
  173. package/dist/service.test.js +266 -77
  174. package/dist/service.test.js.map +1 -1
  175. package/dist/test/index.d.ts +33 -0
  176. package/dist/test/index.d.ts.map +1 -0
  177. package/dist/test/index.js +44 -0
  178. package/dist/test/index.js.map +1 -0
  179. package/dist/test-utils/channel-service-fixture.d.ts +2 -1
  180. package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
  181. package/dist/test-utils/channel-service-fixture.js +31 -11
  182. package/dist/test-utils/channel-service-fixture.js.map +1 -1
  183. package/dist/test-utils/conformance-adapter.d.ts +50 -0
  184. package/dist/test-utils/conformance-adapter.d.ts.map +1 -0
  185. package/dist/test-utils/conformance-adapter.js +206 -0
  186. package/dist/test-utils/conformance-adapter.js.map +1 -0
  187. package/dist/test-utils/fake-service.d.ts +85 -0
  188. package/dist/test-utils/fake-service.d.ts.map +1 -0
  189. package/dist/test-utils/fake-service.js +103 -0
  190. package/dist/test-utils/fake-service.js.map +1 -0
  191. package/dist/test-utils/index.d.ts +2 -0
  192. package/dist/test-utils/index.d.ts.map +1 -1
  193. package/dist/test-utils/index.js +3 -2
  194. package/dist/test-utils/index.js.map +1 -1
  195. package/dist/ws-client.d.ts +157 -18
  196. package/dist/ws-client.d.ts.map +1 -1
  197. package/dist/ws-client.js +432 -94
  198. package/dist/ws-client.js.map +1 -1
  199. package/dist/ws-client.test.d.ts +2 -0
  200. package/dist/ws-client.test.d.ts.map +1 -0
  201. package/dist/ws-client.test.js +672 -0
  202. package/dist/ws-client.test.js.map +1 -0
  203. package/package.json +23 -14
  204. package/LICENSE +0 -21
  205. package/dist/__tests__/service.integration.test.d.ts +0 -2
  206. package/dist/__tests__/service.integration.test.d.ts.map +0 -1
  207. package/dist/__tests__/service.integration.test.js +0 -1087
  208. package/dist/__tests__/service.integration.test.js.map +0 -1
  209. package/dist/cli/commands/delete.d.ts +0 -3
  210. package/dist/cli/commands/delete.d.ts.map +0 -1
  211. package/dist/cli/commands/delete.js +0 -10
  212. package/dist/cli/commands/delete.js.map +0 -1
  213. package/dist/cli/commands/react.d.ts +0 -3
  214. package/dist/cli/commands/react.d.ts.map +0 -1
  215. package/dist/cli/commands/react.js +0 -18
  216. package/dist/cli/commands/react.js.map +0 -1
@@ -0,0 +1,672 @@
1
+ /**
2
+ * Tests for `ws-client.ts` — now running against a real in-process
3
+ * `@effect/platform` WebSocket server instead of a `vi.mock("ws")` fake.
4
+ *
5
+ * Setup: each test spins up a fresh `NodeSocketServer.makeWebSocket` bound to
6
+ * `127.0.0.1:0` (OS-assigned port). An explicit host is required — omitting
7
+ * it binds `::` which `server.address()` returns verbatim, yielding a
8
+ * non-dialable `ws://:::PORT` URL on Linux/macOS (gotcha §4.11).
9
+ *
10
+ * Coverage matches the §5 invariants + the typed-manifest + malformed-frame
11
+ * cadence tests from the legacy suite. Reconnect-backoff uses real wall-clock
12
+ * timing because the reconnect loop runs on the client's internal
13
+ * `ManagedRuntime`, whose default Clock is out of reach of a test-fiber's
14
+ * `TestClock` (see the `describe("reconnect backoff")` block for details).
15
+ */
16
+ import { execSync } from "node:child_process";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+ import { it as itEffect } from "@effect/vitest";
19
+ import { Cause, Deferred, Duration, Effect, Exit, Fiber, Option, Scope, TestClock, } from "effect";
20
+ import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer";
21
+ import * as Socket from "@effect/platform/Socket";
22
+ import { PROTOCOL_VERSION } from "@moltzap/protocol";
23
+ import { MoltZapWsClient, RPC_TIMEOUT_MS, } from "./ws-client.js";
24
+ import { RpcTimeoutError } from "./runtime/errors.js";
25
+ /**
26
+ * Spin up an in-process `@effect/platform` WS server on `127.0.0.1:0`.
27
+ * Caller owns the provided scope; when it closes, the server shuts down.
28
+ */
29
+ const startTestServer = (handler) => Effect.gen(function* () {
30
+ const server = yield* NodeSocketServer.makeWebSocket({
31
+ port: 0,
32
+ host: "127.0.0.1",
33
+ });
34
+ const addr = server.address;
35
+ if (addr._tag !== "TcpAddress") {
36
+ return yield* Effect.die("expected TcpAddress");
37
+ }
38
+ const connections = [];
39
+ yield* Effect.forkScoped(server
40
+ .run((serverSock) => Effect.gen(function* () {
41
+ const write = yield* serverSock.writer;
42
+ const receivedList = [];
43
+ const conn = {
44
+ send: (raw) => write(raw).pipe(Effect.ignore),
45
+ close: (code = 1000, reason = "test close") => write(new Socket.CloseEvent(code, reason)).pipe(Effect.ignore),
46
+ get received() {
47
+ return receivedList;
48
+ },
49
+ };
50
+ connections.push(conn);
51
+ yield* serverSock.runRaw((data) => Effect.gen(function* () {
52
+ const raw = typeof data === "string"
53
+ ? data
54
+ : new TextDecoder("utf-8").decode(data);
55
+ receivedList.push(raw);
56
+ yield* handler(conn, raw);
57
+ }));
58
+ }))
59
+ .pipe(Effect.ignore));
60
+ return {
61
+ url: `http://${addr.hostname}:${addr.port}`,
62
+ get connections() {
63
+ return connections;
64
+ },
65
+ };
66
+ });
67
+ // ── Logger helper ──────────────────────────────────────────────────────
68
+ function makeLogger() {
69
+ return {
70
+ info: vi.fn(),
71
+ warn: vi.fn(),
72
+ error: vi.fn(),
73
+ };
74
+ }
75
+ const connectP = (client) => Effect.runPromise(client.connect().pipe(Effect.catchTag("RpcTimeoutError", (err) => Effect.fail(new Error(`RPC timeout: ${err.method}`))), Effect.catchAll((err) => Effect.fail(new Error(err.message)))));
76
+ const sendRpcP = (client, method, params) => Effect.runPromise(client.sendRpc(method, params).pipe(Effect.catchTag("RpcTimeoutError", (err) => Effect.fail(new Error(`RPC timeout: ${err.method}`))), Effect.catchAll((err) => Effect.fail(new Error(err.message)))));
77
+ const closeClient = (client) => client.close();
78
+ /**
79
+ * Start a server whose handler auto-responds to `auth/connect` and forwards
80
+ * everything else to the test's `handler`. Useful for tests that only care
81
+ * about post-handshake behaviour.
82
+ */
83
+ const startHandshakingServer = (handler) => startTestServer((conn, raw) => Effect.gen(function* () {
84
+ const frame = JSON.parse(raw);
85
+ if (frame.method === "auth/connect") {
86
+ yield* conn.send(JSON.stringify({
87
+ jsonrpc: "2.0",
88
+ type: "response",
89
+ id: frame.id,
90
+ result: { agentId: "agent-xyz", protocol: PROTOCOL_VERSION },
91
+ }));
92
+ return;
93
+ }
94
+ yield* handler(conn, raw, frame);
95
+ }));
96
+ /**
97
+ * Promise-returning wrapper that both runs a test Effect and keeps the scope
98
+ * alive until the Effect completes. Tests that need a server scope should
99
+ * use `withTestServer` rather than mutating a Scope manually.
100
+ */
101
+ const withTestServer = async (effect) => {
102
+ const scope = Effect.runSync(Scope.make());
103
+ try {
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ const typed = Scope.extend(effect, scope);
106
+ return await Effect.runPromise(typed);
107
+ }
108
+ finally {
109
+ await Effect.runPromise(Scope.close(scope, Exit.void));
110
+ }
111
+ };
112
+ /**
113
+ * Poll until `pred()` is true or `maxMs` elapses. The legacy test file used a
114
+ * microtask-based loop; we keep that cadence to match the legacy behaviour
115
+ * when synchronisation is driven by wall-clock timing in the test harness.
116
+ */
117
+ async function waitFor(pred, { maxMs = 2000 } = {}) {
118
+ const deadline = Date.now() + maxMs;
119
+ while (!pred()) {
120
+ if (Date.now() > deadline) {
121
+ throw new Error("waitFor: condition not satisfied in time");
122
+ }
123
+ await new Promise((r) => setTimeout(r, 5));
124
+ }
125
+ }
126
+ /**
127
+ * Build a `MoltZapWsClient`. Spec #222 OQ-4 deletion: `onEvent` is no
128
+ * longer a constructor option; tests that previously stashed an
129
+ * `onEvent` callback now register a `subscribe({}, …)` subscription
130
+ * post-construction. The helper accepts the same `onEvent` callback as
131
+ * a convenience and wires it through the new subscription registry,
132
+ * keeping migration noise local to the helper.
133
+ */
134
+ function makeClient(url, overrides) {
135
+ const client = new MoltZapWsClient({
136
+ serverUrl: url,
137
+ agentKey: "test-key",
138
+ onDisconnect: overrides?.onDisconnect ?? ((_close) => { }),
139
+ onReconnect: overrides?.onReconnect ?? (() => { }),
140
+ logger: overrides?.logger ?? makeLogger(),
141
+ });
142
+ if (overrides?.onEvent !== undefined) {
143
+ const cb = overrides.onEvent;
144
+ Effect.runSync(client.subscribe({}, (frame) => Effect.sync(() => cb(frame))));
145
+ }
146
+ return client;
147
+ }
148
+ function parseSent(raw) {
149
+ return JSON.parse(raw);
150
+ }
151
+ // ── Tests ──────────────────────────────────────────────────────────────
152
+ beforeEach(() => {
153
+ // Nothing global to reset; each test owns its server scope.
154
+ });
155
+ afterEach(() => {
156
+ // Nothing to clean up — test scopes close their own servers.
157
+ });
158
+ // ─────────────────────────────────────────────────────────────────────
159
+ // §5.1 — connect() must not hang on pre-open close/error
160
+ // ─────────────────────────────────────────────────────────────────────
161
+ describe("§5.1 connect() does not hang on pre-open failure", () => {
162
+ it("rejects immediately when the server closes the connection before handshake", async () => {
163
+ await withTestServer(Effect.gen(function* () {
164
+ // Handler closes on the very first inbound frame — i.e. before the
165
+ // client sees any auth/connect response.
166
+ const server = yield* startTestServer((conn) => conn.close(1000).pipe(Effect.ignore));
167
+ const client = makeClient(server.url);
168
+ const t0 = Date.now();
169
+ yield* Effect.promise(() => expect(connectP(client)).rejects.toThrow(/WebSocket not connected/));
170
+ const elapsed = Date.now() - t0;
171
+ expect(elapsed).toBeLessThan(3000);
172
+ yield* closeClient(client);
173
+ }));
174
+ });
175
+ it("rejects when the server refuses the connection (well within RPC timeout)", async () => {
176
+ // Point the client at a port that's not accepting connections. The
177
+ // `ws` lib emits 'error' on TCP connect failure which maps to
178
+ // `SocketGenericError{reason: "Open"}`. Our reader fiber's `onExit`
179
+ // catches and fails the handshake deferred → NotConnectedError.
180
+ //
181
+ // Observed: ECONNREFUSED fires in single-digit ms locally; give
182
+ // generous CI headroom via the 15s assertion but keep test timeout at
183
+ // 20s to avoid flakes on slow runners.
184
+ const client = makeClient("http://127.0.0.1:1");
185
+ const t0 = Date.now();
186
+ try {
187
+ await connectP(client);
188
+ throw new Error("expected connect to reject");
189
+ }
190
+ catch (err) {
191
+ expect(err.message).toMatch(/WebSocket not connected/);
192
+ }
193
+ const elapsed = Date.now() - t0;
194
+ expect(elapsed).toBeLessThan(15_000);
195
+ await Effect.runPromise(client.close());
196
+ }, 20_000);
197
+ it("resolves with HelloOk on the happy open → auth/connect path", async () => {
198
+ await withTestServer(Effect.gen(function* () {
199
+ const server = yield* startHandshakingServer(() => Effect.void);
200
+ const client = makeClient(server.url);
201
+ const hello = (yield* Effect.promise(() => connectP(client)));
202
+ expect(hello.agentId).toBe("agent-xyz");
203
+ expect(client.helloOk).toEqual(hello);
204
+ yield* closeClient(client);
205
+ }));
206
+ });
207
+ });
208
+ // ─────────────────────────────────────────────────────────────────────
209
+ // §5.2 — pending RPCs fail fast on disconnect
210
+ // ─────────────────────────────────────────────────────────────────────
211
+ describe("§5.2 pending RPCs fail on disconnect", () => {
212
+ it("rejects pending sendRpc calls when disconnect() is called", async () => {
213
+ await withTestServer(Effect.gen(function* () {
214
+ // Handler responds to auth/connect but drops everything else, so the
215
+ // RPC stays pending until we trigger disconnect.
216
+ const server = yield* startHandshakingServer(() => Effect.void);
217
+ const client = makeClient(server.url);
218
+ yield* Effect.promise(() => connectP(client));
219
+ const rpcP = sendRpcP(client, "messages/send", {
220
+ conversationId: "c1",
221
+ parts: [{ type: "text", text: "hi" }],
222
+ });
223
+ // Wait for the RPC frame to land on the server.
224
+ yield* Effect.promise(() => waitFor(() => server.connections[0].received.length >= 2));
225
+ // Trigger disconnect — the reader-fiber `onExit` path drains pendings
226
+ // with NotConnectedError.
227
+ yield* client.disconnect();
228
+ yield* Effect.promise(() => expect(rpcP).rejects.toThrow(/WebSocket not connected/));
229
+ yield* closeClient(client);
230
+ }));
231
+ });
232
+ });
233
+ // ─────────────────────────────────────────────────────────────────────
234
+ // §5.3 — no automatic retry on timeout (TestClock-driven)
235
+ // ─────────────────────────────────────────────────────────────────────
236
+ //
237
+ // `sendRpcEffect` wraps `Deferred.await` with `Effect.timeoutFail`, which
238
+ // schedules against the Effect Clock. Under `@effect/vitest`'s `it.effect`
239
+ // that Clock is a TestClock, so `TestClock.adjust(Duration.millis(30_000))`
240
+ // advances virtual time and fires the timeout synchronously.
241
+ //
242
+ // Layering caveat: `Effect.sleep` (TestClock) and real DOM WebSocket events
243
+ // (real timers) live on different clocks. The handshake runs without any
244
+ // `Effect.sleep`, so it completes against wall-clock. We poll server-side
245
+ // frame arrival via real setTimeout (sidesteps TestClock) so the test can
246
+ // wait for the RPC frame to land before advancing virtual time.
247
+ describe("§5.3 sendRpc does NOT retry on timeout (TestClock)", () => {
248
+ itEffect.scoped("fails with RpcTimeoutError after virtual 30s, no retry frame", () => Effect.gen(function* () {
249
+ // Server answers auth/connect, then silently drops messages/send.
250
+ const server = yield* startHandshakingServer(() => Effect.void);
251
+ const client = makeClient(server.url);
252
+ // Handshake: not wrapped in Effect.sleep, so TestClock doesn't
253
+ // interfere.
254
+ yield* Effect.promise(() => connectP(client));
255
+ const serverConn = server.connections[0];
256
+ const beforeCount = serverConn.received.length;
257
+ const rpcFiber = yield* Effect.fork(client.sendRpc("messages/send", {
258
+ conversationId: "c1",
259
+ parts: [{ type: "text", text: "payload" }],
260
+ }));
261
+ // Wait for the frame to land on the server using real-time polling.
262
+ // `Effect.async` sidesteps TestClock: the callback fires when our
263
+ // setTimeout triggers on the real event loop.
264
+ yield* Effect.async((resume) => {
265
+ const deadlineMs = Date.now() + 2000;
266
+ const tick = () => {
267
+ if (serverConn.received.length > beforeCount) {
268
+ resume(Effect.void);
269
+ return;
270
+ }
271
+ if (Date.now() > deadlineMs) {
272
+ resume(Effect.void);
273
+ return;
274
+ }
275
+ setTimeout(tick, 5);
276
+ };
277
+ setTimeout(tick, 5);
278
+ });
279
+ expect(serverConn.received.length).toBe(beforeCount + 1);
280
+ // Virtual time: advance past RPC_TIMEOUT_MS → timeoutFail fires.
281
+ yield* TestClock.adjust(Duration.millis(RPC_TIMEOUT_MS));
282
+ const exit = yield* Fiber.await(rpcFiber);
283
+ expect(Exit.isFailure(exit)).toBe(true);
284
+ if (Exit.isFailure(exit)) {
285
+ // Cause.failureOption narrows the cause to its typed error without
286
+ // touching `_tag` manually. instanceof pins the class nominally
287
+ // in case a defect slipped through with a matching shape.
288
+ const failed = Cause.failureOption(exit.cause);
289
+ expect(Option.isSome(failed)).toBe(true);
290
+ if (Option.isSome(failed)) {
291
+ const err = failed.value;
292
+ expect(err).toBeInstanceOf(RpcTimeoutError);
293
+ if (err instanceof RpcTimeoutError) {
294
+ expect(err.method).toBe("messages/send");
295
+ expect(err.timeoutMs).toBe(RPC_TIMEOUT_MS);
296
+ }
297
+ }
298
+ }
299
+ // No retry frame may have been enqueued — timeout is terminal. Bounce
300
+ // once through the real event loop for any stragglers.
301
+ yield* Effect.async((resume) => {
302
+ setTimeout(() => resume(Effect.void), 50);
303
+ });
304
+ expect(serverConn.received.length).toBe(beforeCount + 1);
305
+ yield* client.close();
306
+ }));
307
+ });
308
+ // ─────────────────────────────────────────────────────────────────────
309
+ // Reconnect backoff — real wall-clock
310
+ // ─────────────────────────────────────────────────────────────────────
311
+ //
312
+ // The reconnect loop is forked on the client's internal `ManagedRuntime`
313
+ // (created in the constructor to provide `NodeSocket.layerWebSocketConstructor`
314
+ // without bubbling the requirement to callers). That runtime has its own
315
+ // default Clock, so `TestClock.adjust` from a test fiber doesn't release
316
+ // `Effect.sleep`s scheduled inside it. We assert on real wall-clock timing
317
+ // instead; the jittered base delay is [0, 1s] so 2.5s is a safe ceiling.
318
+ describe("reconnect backoff", () => {
319
+ it("reconnects with exponential-jittered backoff after unsolicited server close", async () => {
320
+ await withTestServer(Effect.gen(function* () {
321
+ let authResponsesSent = 0;
322
+ let reconnectHello = null;
323
+ const server = yield* startTestServer((conn, raw) => Effect.gen(function* () {
324
+ const frame = JSON.parse(raw);
325
+ if (frame.method === "auth/connect") {
326
+ authResponsesSent++;
327
+ yield* conn.send(JSON.stringify({
328
+ jsonrpc: "2.0",
329
+ type: "response",
330
+ id: frame.id,
331
+ result: { agentId: "agent-1" },
332
+ }));
333
+ }
334
+ }));
335
+ const client = makeClient(server.url, {
336
+ onReconnect: (hello) => {
337
+ reconnectHello = hello;
338
+ },
339
+ });
340
+ yield* Effect.promise(() => connectP(client));
341
+ expect(authResponsesSent).toBe(1);
342
+ expect(server.connections.length).toBe(1);
343
+ // Kill the server-side connection. The client's reader sees a close,
344
+ // fails pendings, invokes onDisconnect, and schedules a reconnect
345
+ // via `Effect.sleep` + `Schedule.jittered`.
346
+ yield* server.connections[0].close(1000);
347
+ yield* Effect.promise(() => waitFor(() => server.connections.length >= 2 && authResponsesSent >= 2, { maxMs: 2500 }));
348
+ expect(server.connections.length).toBeGreaterThanOrEqual(2);
349
+ expect(authResponsesSent).toBeGreaterThanOrEqual(2);
350
+ yield* Effect.promise(() => waitFor(() => reconnectHello !== null, { maxMs: 500 }));
351
+ expect(reconnectHello.agentId).toBe("agent-1");
352
+ yield* closeClient(client);
353
+ }));
354
+ });
355
+ });
356
+ // ─────────────────────────────────────────────────────────────────────
357
+ // §5.4 — malformed inbound frames logged + ignored, not crashing
358
+ // ─────────────────────────────────────────────────────────────────────
359
+ describe("§5.4 malformed frames are logged but do not affect pending RPCs", () => {
360
+ it("ignores non-JSON frames while a pending RPC is outstanding, then resolves on the real response", async () => {
361
+ await withTestServer(Effect.gen(function* () {
362
+ const logger = makeLogger();
363
+ // Server auto-handshakes. On `conversations/list`, injects a few
364
+ // malformed inbound frames, then the real response.
365
+ const server = yield* startHandshakingServer((conn, _raw, frame) => Effect.gen(function* () {
366
+ if (frame.method !== "conversations/list")
367
+ return;
368
+ // Inject: non-JSON, then missing-id response, then unknown type.
369
+ yield* conn.send("not json at all");
370
+ yield* conn.send(JSON.stringify({ type: "response" }));
371
+ yield* conn.send(JSON.stringify({ type: "unknown", id: frame.id }));
372
+ // Real well-formed response.
373
+ yield* conn.send(JSON.stringify({
374
+ jsonrpc: "2.0",
375
+ type: "response",
376
+ id: frame.id,
377
+ result: { conversations: [] },
378
+ }));
379
+ }));
380
+ const client = makeClient(server.url, { logger });
381
+ yield* Effect.promise(() => connectP(client));
382
+ const result = (yield* Effect.promise(() => sendRpcP(client, "conversations/list", {})));
383
+ expect(result.conversations).toEqual([]);
384
+ // Logger saw at least one malformed-frame warning.
385
+ expect(logger.warn).toHaveBeenCalled();
386
+ yield* closeClient(client);
387
+ }));
388
+ });
389
+ it("accepts a padded chunk that contains both an event and the response", async () => {
390
+ await withTestServer(Effect.gen(function* () {
391
+ const logger = makeLogger();
392
+ const events = [];
393
+ const server = yield* startHandshakingServer((conn, _raw, frame) => Effect.gen(function* () {
394
+ if (frame.method !== "conversations/list")
395
+ return;
396
+ yield* conn.send(JSON.stringify({
397
+ jsonrpc: "2.0",
398
+ type: "event",
399
+ event: "messages/received",
400
+ data: { message: { id: "m-1", conversationId: "c-1" } },
401
+ }) +
402
+ "\u0000" +
403
+ JSON.stringify({
404
+ jsonrpc: "2.0",
405
+ type: "response",
406
+ id: frame.id,
407
+ result: { conversations: [] },
408
+ }));
409
+ }));
410
+ const client = makeClient(server.url, {
411
+ logger,
412
+ onEvent: (event) => events.push(event),
413
+ });
414
+ yield* Effect.promise(() => connectP(client));
415
+ const result = (yield* Effect.promise(() => sendRpcP(client, "conversations/list", {})));
416
+ expect(result.conversations).toEqual([]);
417
+ expect(events).toHaveLength(1);
418
+ expect(events[0]).toMatchObject({ event: "messages/received" });
419
+ expect(logger.warn).not.toHaveBeenCalled();
420
+ yield* closeClient(client);
421
+ }));
422
+ });
423
+ it("routes a well-formed event frame to onEvent", async () => {
424
+ await withTestServer(Effect.gen(function* () {
425
+ const events = [];
426
+ const server = yield* startHandshakingServer((conn) => conn.send(JSON.stringify({
427
+ jsonrpc: "2.0",
428
+ type: "event",
429
+ event: "message.received",
430
+ data: { message: { id: "m-1", conversationId: "c-1" } },
431
+ })));
432
+ const client = makeClient(server.url, {
433
+ onEvent: (e) => events.push(e),
434
+ });
435
+ yield* Effect.promise(() => connectP(client));
436
+ // Fire-and-forget: the server responds with an out-of-band event
437
+ // rather than an RPC response, so the noop Deferred never resolves.
438
+ // Awaiting it would wedge the test for the full RPC_TIMEOUT_MS.
439
+ void sendRpcP(client, "noop", {}).catch(() => { });
440
+ yield* Effect.promise(() => waitFor(() => events.length > 0, { maxMs: 2000 }));
441
+ expect(events[0]).toMatchObject({
442
+ type: "event",
443
+ event: "message.received",
444
+ });
445
+ yield* closeClient(client);
446
+ }));
447
+ });
448
+ it("does NOT route an event frame missing the event name field", async () => {
449
+ await withTestServer(Effect.gen(function* () {
450
+ const events = [];
451
+ const logger = makeLogger();
452
+ // Send a malformed event on the first post-handshake frame.
453
+ const server = yield* startHandshakingServer((conn) => conn.send(JSON.stringify({ type: "event", data: {} })));
454
+ const client = makeClient(server.url, {
455
+ onEvent: (e) => events.push(e),
456
+ logger,
457
+ });
458
+ yield* Effect.promise(() => connectP(client));
459
+ // Fire-and-forget: see the well-formed-event test above for rationale.
460
+ void sendRpcP(client, "noop", {}).catch(() => { });
461
+ yield* Effect.promise(() => new Promise((r) => setTimeout(r, 50)));
462
+ expect(events).toHaveLength(0);
463
+ expect(logger.warn).toHaveBeenCalled();
464
+ yield* closeClient(client);
465
+ }));
466
+ });
467
+ });
468
+ // ─────────────────────────────────────────────────────────────────────
469
+ // Malformed-frame log cadence
470
+ // ─────────────────────────────────────────────────────────────────────
471
+ describe("malformed-frame log cadence (MALFORMED_LOG_EVERY)", () => {
472
+ it("logs at frames #1, #50, #100 and suppresses everything in between", async () => {
473
+ await withTestServer(Effect.gen(function* () {
474
+ const logger = makeLogger();
475
+ // Server auto-handshakes. On ANY post-handshake frame, fires 101
476
+ // malformed frames back at the client.
477
+ const server = yield* startHandshakingServer((conn) => Effect.gen(function* () {
478
+ for (let i = 0; i < 101; i++) {
479
+ yield* conn.send("definitely not json " + i);
480
+ }
481
+ }));
482
+ const client = makeClient(server.url, { logger });
483
+ yield* Effect.promise(() => connectP(client));
484
+ logger.warn.mockClear();
485
+ // Fire-and-forget: server responds with 101 malformed frames, no
486
+ // actual RPC response, so awaiting the noop would wedge the test.
487
+ void sendRpcP(client, "noop", {}).catch(() => { });
488
+ // Wait for the malformed frames to flush through the reader fiber.
489
+ yield* Effect.promise(() => new Promise((r) => setTimeout(r, 300)));
490
+ expect(logger.warn).toHaveBeenCalledTimes(3);
491
+ const warnMessages = logger.warn.mock.calls.map((c) => c[0]);
492
+ expect(warnMessages[0]).toMatch(/^Malformed frame \(#1\):/);
493
+ expect(warnMessages[1]).toMatch(/^Malformed frame \(#50\):/);
494
+ expect(warnMessages[2]).toMatch(/^Malformed frame \(#100\):/);
495
+ yield* closeClient(client);
496
+ }));
497
+ });
498
+ });
499
+ // ─────────────────────────────────────────────────────────────────────
500
+ // close() vs pending-RPC interleave
501
+ // ─────────────────────────────────────────────────────────────────────
502
+ describe("close() interleaved with a pending RPC", () => {
503
+ it("rejects the pending RPC with NotConnectedError before any timeout", async () => {
504
+ await withTestServer(Effect.gen(function* () {
505
+ // Server handshakes then drops everything.
506
+ const server = yield* startHandshakingServer(() => Effect.void);
507
+ const client = makeClient(server.url);
508
+ yield* Effect.promise(() => connectP(client));
509
+ const rpcP = sendRpcP(client, "conversations/list", {});
510
+ yield* Effect.promise(() => waitFor(() => server.connections[0].received.length >= 2));
511
+ const beforeMs = Date.now();
512
+ yield* closeClient(client);
513
+ yield* Effect.promise(() => expect(rpcP).rejects.toThrow(/WebSocket not connected/));
514
+ expect(Date.now() - beforeMs).toBeLessThan(1000);
515
+ }));
516
+ });
517
+ });
518
+ // ─────────────────────────────────────────────────────────────────────
519
+ // ws.on("error") → close propagation
520
+ // ─────────────────────────────────────────────────────────────────────
521
+ describe("socket error after connect", () => {
522
+ it("pending RPCs reject with NotConnectedError after the server closes", async () => {
523
+ await withTestServer(Effect.gen(function* () {
524
+ const logger = makeLogger();
525
+ // Handshakes, then on any follow-up RPC, closes abruptly (code 1011).
526
+ const server = yield* startHandshakingServer((conn) => conn.close(1011));
527
+ const client = makeClient(server.url, { logger });
528
+ yield* Effect.promise(() => connectP(client));
529
+ const rpcP = sendRpcP(client, "conversations/list", {});
530
+ yield* Effect.promise(() => expect(rpcP).rejects.toThrow(/WebSocket not connected/));
531
+ // Logger captured the WebSocket error (warn level).
532
+ expect(logger.warn).toHaveBeenCalled();
533
+ yield* closeClient(client);
534
+ }));
535
+ });
536
+ });
537
+ // ─────────────────────────────────────────────────────────────────────
538
+ // Typed-manifest sendRpc overload
539
+ // ─────────────────────────────────────────────────────────────────────
540
+ describe("sendRpc(RpcDefinition, params) — typed manifest overload", () => {
541
+ it("uses definition.name as the wire-level method string", async () => {
542
+ await withTestServer(Effect.gen(function* () {
543
+ const { AgentsLookupByName } = yield* Effect.promise(() => import("@moltzap/protocol"));
544
+ // Echo the method name + params back as the result so the test can
545
+ // verify both the wire-level method and the forwarded params.
546
+ const server = yield* startHandshakingServer((conn, _raw, frame) => conn.send(JSON.stringify({
547
+ jsonrpc: "2.0",
548
+ type: "response",
549
+ id: frame.id,
550
+ result: {
551
+ echoedMethod: frame.method,
552
+ echoedParams: frame.params,
553
+ agents: [],
554
+ },
555
+ })));
556
+ const client = makeClient(server.url);
557
+ yield* Effect.promise(() => connectP(client));
558
+ const result = (yield* Effect.promise(() => Effect.runPromise(client.sendRpc(AgentsLookupByName, { names: ["alice"] }))));
559
+ expect(result.echoedMethod).toBe("agents/lookupByName");
560
+ expect(result.echoedParams).toEqual({ names: ["alice"] });
561
+ yield* closeClient(client);
562
+ }));
563
+ });
564
+ it("preserves the legacy string overload for back-compat", async () => {
565
+ await withTestServer(Effect.gen(function* () {
566
+ const server = yield* startHandshakingServer((conn, _raw, frame) => conn.send(JSON.stringify({
567
+ jsonrpc: "2.0",
568
+ type: "response",
569
+ id: frame.id,
570
+ result: { echoedMethod: frame.method, agents: [] },
571
+ })));
572
+ const client = makeClient(server.url);
573
+ yield* Effect.promise(() => connectP(client));
574
+ const result = (yield* Effect.promise(() => sendRpcP(client, "agents/lookupByName", { names: ["bob"] })));
575
+ expect(result.echoedMethod).toBe("agents/lookupByName");
576
+ yield* closeClient(client);
577
+ }));
578
+ });
579
+ });
580
+ // ─────────────────────────────────────────────────────────────────────
581
+ // AC2 — graceful close drains ESTABLISHED sockets (socket-count evidence)
582
+ // ─────────────────────────────────────────────────────────────────────
583
+ describe("graceful close drains ESTABLISHED sockets (AC2)", () => {
584
+ it("10 clients close cleanly: no CLOSE_WAIT sockets on the server port", async () => {
585
+ await withTestServer(Effect.gen(function* () {
586
+ const server = yield* startHandshakingServer(() => Effect.void);
587
+ const port = new URL(server.url).port;
588
+ // Connect 10 clients concurrently.
589
+ const clients = yield* Effect.all(Array.from({ length: 10 }, () => {
590
+ const c = makeClient(server.url);
591
+ return Effect.promise(() => connectP(c)).pipe(Effect.map(() => c));
592
+ }), { concurrency: "unbounded" });
593
+ expect(server.connections.length).toBe(10);
594
+ // Close all 10 via the graceful Effect-based close().
595
+ yield* Effect.all(clients.map((c) => c.close()), { concurrency: "unbounded" });
596
+ // Brief pause for TCP close handshake to complete.
597
+ yield* Effect.sleep("150 millis");
598
+ // Assert no CLOSE_WAIT connections remain on the server port.
599
+ const ssOut = yield* Effect.sync(() => execSync("ss -tn state CLOSE-WAIT 2>/dev/null || true", {
600
+ encoding: "utf-8",
601
+ }));
602
+ const stale = ssOut
603
+ .split("\n")
604
+ .filter((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`));
605
+ expect(stale).toHaveLength(0);
606
+ }));
607
+ }, 15_000);
608
+ });
609
+ // ─────────────────────────────────────────────────────────────────────
610
+ // Spec #222 §5.4 (V7) — `onDisconnect` receives a typed `CloseInfo`
611
+ // ─────────────────────────────────────────────────────────────────────
612
+ describe("spec #222 §5.4 — onDisconnect close metadata (V7)", () => {
613
+ // Two distinguishable closes through the live reader-fiber path:
614
+ // 1. Server-initiated 1000/"bye" → CloseInfo{1000, "normal"}
615
+ // (`@effect/platform/Socket` treats 1000 as `Exit.Success` per
616
+ // its default `closeCodeIsError`; `extractCloseInfo` synthesizes
617
+ // the OQ-5 graceful default — the close-frame reason is dropped
618
+ // because there's no `SocketCloseError` to round-trip.)
619
+ // 2. Server-initiated 1011/"boom" → CloseInfo{1011, "boom"}
620
+ // (non-1000 codes raise `SocketCloseError`; the real code +
621
+ // `closeReason` round-trip through.)
622
+ //
623
+ // The pre-deletion adapter hardcoded `{1000, "disconnect"}` regardless
624
+ // of the actual close. A V7 mutation that re-introduces the hardcode
625
+ // flips the second test's `code === 1011` assertion pass → fail,
626
+ // satisfying AC 5.4-4 (distinguishable payloads). The OQ-5
627
+ // default-fallback paths (HandshakeFailure / TransportFailure /
628
+ // Unknown → DEFAULT_ABNORMAL_CLOSE) are covered by the direct unit
629
+ // tests in `runtime/close-info.test.ts`, where synthetic `Exit`
630
+ // values exercise each branch without a real transport.
631
+ it("synthesizes the graceful default when the transport treats 1000 as Exit.Success", async () => {
632
+ await withTestServer(Effect.gen(function* () {
633
+ const closes = [];
634
+ const server = yield* startHandshakingServer(() => Effect.void);
635
+ const client = makeClient(server.url, {
636
+ onDisconnect: (close) => {
637
+ closes.push(close);
638
+ },
639
+ });
640
+ yield* Effect.promise(() => connectP(client));
641
+ yield* server.connections[0].close(1000, "bye");
642
+ yield* Effect.promise(() => waitFor(() => closes.length > 0, { maxMs: 2000 }));
643
+ yield* closeClient(client);
644
+ expect(closes.length).toBeGreaterThanOrEqual(1);
645
+ const first = closes[0];
646
+ expect(first.code).toBe(1000);
647
+ expect(first.reason).toBe("normal"); // OQ-5 default, not "bye"
648
+ }));
649
+ });
650
+ it("round-trips the server's close code + reason for a 1011 (server error) close", async () => {
651
+ await withTestServer(Effect.gen(function* () {
652
+ const closes = [];
653
+ const server = yield* startHandshakingServer(() => Effect.void);
654
+ const client = makeClient(server.url, {
655
+ onDisconnect: (close) => {
656
+ closes.push(close);
657
+ },
658
+ });
659
+ yield* Effect.promise(() => connectP(client));
660
+ yield* server.connections[0].close(1011, "boom");
661
+ yield* Effect.promise(() => waitFor(() => closes.length > 0, { maxMs: 2000 }));
662
+ yield* closeClient(client);
663
+ expect(closes.length).toBeGreaterThanOrEqual(1);
664
+ const first = closes[0];
665
+ // Distinguishable from the 1000 test above (AC 5.4-4):
666
+ // a hardcoded `{1000, "disconnect"}` would fail this assertion.
667
+ expect(first.code).toBe(1011);
668
+ expect(first.reason).toBe("boom");
669
+ }));
670
+ });
671
+ });
672
+ //# sourceMappingURL=ws-client.test.js.map