@moltzap/client 2026.511.0 → 2026.518.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 (194) hide show
  1. package/dist/auth.d.ts +12 -6
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/auth.js +67 -29
  4. package/dist/auth.js.map +1 -1
  5. package/dist/auth.test.js +113 -76
  6. package/dist/auth.test.js.map +1 -1
  7. package/dist/channel-core-context.test.d.ts +2 -0
  8. package/dist/channel-core-context.test.d.ts.map +1 -0
  9. package/dist/channel-core-context.test.js +334 -0
  10. package/dist/channel-core-context.test.js.map +1 -0
  11. package/dist/channel-core-dispatch.test.d.ts +2 -0
  12. package/dist/channel-core-dispatch.test.d.ts.map +1 -0
  13. package/dist/channel-core-dispatch.test.js +550 -0
  14. package/dist/channel-core-dispatch.test.js.map +1 -0
  15. package/dist/channel-core-enrichment.d.ts +8 -0
  16. package/dist/channel-core-enrichment.d.ts.map +1 -0
  17. package/dist/channel-core-enrichment.js +134 -0
  18. package/dist/channel-core-enrichment.js.map +1 -0
  19. package/dist/channel-core-errors.d.ts +20 -0
  20. package/dist/channel-core-errors.d.ts.map +1 -0
  21. package/dist/channel-core-errors.js +12 -0
  22. package/dist/channel-core-errors.js.map +1 -0
  23. package/dist/channel-core-test-support.d.ts +48 -0
  24. package/dist/channel-core-test-support.d.ts.map +1 -0
  25. package/dist/channel-core-test-support.js +111 -0
  26. package/dist/channel-core-test-support.js.map +1 -0
  27. package/dist/channel-core.d.ts +30 -7
  28. package/dist/channel-core.d.ts.map +1 -1
  29. package/dist/channel-core.js +214 -237
  30. package/dist/channel-core.js.map +1 -1
  31. package/dist/channel-core.test.js +216 -1082
  32. package/dist/channel-core.test.js.map +1 -1
  33. package/dist/cli/__tests__/cli-multi-agent.int.test.js +129 -23
  34. package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -1
  35. package/dist/cli/commands/conversations-v2.test.js +64 -54
  36. package/dist/cli/commands/conversations-v2.test.js.map +1 -1
  37. package/dist/cli/commands/conversations.d.ts +5 -5
  38. package/dist/cli/commands/conversations.d.ts.map +1 -1
  39. package/dist/cli/commands/conversations.js +43 -36
  40. package/dist/cli/commands/conversations.js.map +1 -1
  41. package/dist/cli/commands/messages.d.ts +4 -4
  42. package/dist/cli/commands/messages.d.ts.map +1 -1
  43. package/dist/cli/commands/messages.js +4 -4
  44. package/dist/cli/commands/messages.js.map +1 -1
  45. package/dist/cli/commands/messages.test.js +61 -49
  46. package/dist/cli/commands/messages.test.js.map +1 -1
  47. package/dist/cli/commands/ping.d.ts +0 -9
  48. package/dist/cli/commands/ping.d.ts.map +1 -1
  49. package/dist/cli/commands/ping.js +1 -1
  50. package/dist/cli/commands/ping.js.map +1 -1
  51. package/dist/cli/commands/presence.d.ts +1 -1
  52. package/dist/cli/commands/presence.js +1 -1
  53. package/dist/cli/commands/register.d.ts +2 -2
  54. package/dist/cli/commands/register.d.ts.map +1 -1
  55. package/dist/cli/commands/register.js +98 -77
  56. package/dist/cli/commands/register.js.map +1 -1
  57. package/dist/cli/commands/register.test.js +63 -39
  58. package/dist/cli/commands/register.test.js.map +1 -1
  59. package/dist/cli/commands/send.d.ts +6 -6
  60. package/dist/cli/commands/send.js +6 -6
  61. package/dist/cli/commands/send.test.js +32 -28
  62. package/dist/cli/commands/send.test.js.map +1 -1
  63. package/dist/cli/commands/status.d.ts.map +1 -1
  64. package/dist/cli/commands/status.js +3 -4
  65. package/dist/cli/commands/status.js.map +1 -1
  66. package/dist/cli/config.d.ts +0 -2
  67. package/dist/cli/config.d.ts.map +1 -1
  68. package/dist/cli/config.js +86 -76
  69. package/dist/cli/config.js.map +1 -1
  70. package/dist/cli/config.test.js +80 -42
  71. package/dist/cli/config.test.js.map +1 -1
  72. package/dist/cli/index.d.ts +1 -16
  73. package/dist/cli/index.d.ts.map +1 -1
  74. package/dist/cli/index.js +50 -113
  75. package/dist/cli/index.js.map +1 -1
  76. package/dist/cli/process-argv.d.ts +2 -0
  77. package/dist/cli/process-argv.d.ts.map +1 -0
  78. package/dist/cli/process-argv.js +2 -0
  79. package/dist/cli/process-argv.js.map +1 -0
  80. package/dist/cli/profile.d.ts +3 -10
  81. package/dist/cli/profile.d.ts.map +1 -1
  82. package/dist/cli/profile.js +105 -104
  83. package/dist/cli/profile.js.map +1 -1
  84. package/dist/cli/profile.test.js +338 -232
  85. package/dist/cli/profile.test.js.map +1 -1
  86. package/dist/cli/runtime.d.ts +5 -17
  87. package/dist/cli/runtime.d.ts.map +1 -1
  88. package/dist/cli/runtime.js +6 -59
  89. package/dist/cli/runtime.js.map +1 -1
  90. package/dist/cli/socket-client.d.ts +10 -15
  91. package/dist/cli/socket-client.d.ts.map +1 -1
  92. package/dist/cli/socket-client.js +74 -89
  93. package/dist/cli/socket-client.js.map +1 -1
  94. package/dist/cli/transport.d.ts +9 -5
  95. package/dist/cli/transport.d.ts.map +1 -1
  96. package/dist/cli/transport.js +45 -42
  97. package/dist/cli/transport.js.map +1 -1
  98. package/dist/cli/transport.test.js +227 -133
  99. package/dist/cli/transport.test.js.map +1 -1
  100. package/dist/index.d.ts +4 -1
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +3 -0
  103. package/dist/index.js.map +1 -1
  104. package/dist/local-daemon-rpc.d.ts +13 -0
  105. package/dist/local-daemon-rpc.d.ts.map +1 -0
  106. package/dist/local-daemon-rpc.js +17 -0
  107. package/dist/local-daemon-rpc.js.map +1 -0
  108. package/dist/local-paths.d.ts +7 -0
  109. package/dist/local-paths.d.ts.map +1 -0
  110. package/dist/local-paths.js +20 -0
  111. package/dist/local-paths.js.map +1 -0
  112. package/dist/runtime/close-info.d.ts +3 -12
  113. package/dist/runtime/close-info.d.ts.map +1 -1
  114. package/dist/runtime/close-info.js +28 -39
  115. package/dist/runtime/close-info.js.map +1 -1
  116. package/dist/runtime/close-info.test.js +77 -70
  117. package/dist/runtime/close-info.test.js.map +1 -1
  118. package/dist/runtime/errors.test.js +42 -28
  119. package/dist/runtime/errors.test.js.map +1 -1
  120. package/dist/runtime/frame.d.ts +4 -31
  121. package/dist/runtime/frame.d.ts.map +1 -1
  122. package/dist/runtime/frame.js +7 -116
  123. package/dist/runtime/frame.js.map +1 -1
  124. package/dist/runtime/frame.test.js +41 -54
  125. package/dist/runtime/frame.test.js.map +1 -1
  126. package/dist/runtime/index.d.ts +7 -0
  127. package/dist/runtime/index.d.ts.map +1 -1
  128. package/dist/runtime/index.js +7 -11
  129. package/dist/runtime/index.js.map +1 -1
  130. package/dist/runtime/local-history.d.ts +67 -0
  131. package/dist/runtime/local-history.d.ts.map +1 -0
  132. package/dist/runtime/local-history.js +77 -0
  133. package/dist/runtime/local-history.js.map +1 -0
  134. package/dist/runtime/local-service-commands.d.ts +19 -0
  135. package/dist/runtime/local-service-commands.d.ts.map +1 -1
  136. package/dist/runtime/local-service-commands.js +23 -0
  137. package/dist/runtime/local-service-commands.js.map +1 -1
  138. package/dist/runtime/local-socket-server.d.ts +20 -0
  139. package/dist/runtime/local-socket-server.d.ts.map +1 -0
  140. package/dist/runtime/local-socket-server.js +94 -0
  141. package/dist/runtime/local-socket-server.js.map +1 -0
  142. package/dist/runtime/refs.d.ts +1 -1
  143. package/dist/runtime/refs.js +1 -1
  144. package/dist/runtime/runtime.test.js +29 -21
  145. package/dist/runtime/runtime.test.js.map +1 -1
  146. package/dist/runtime/service-helpers.d.ts +32 -0
  147. package/dist/runtime/service-helpers.d.ts.map +1 -0
  148. package/dist/runtime/service-helpers.js +85 -0
  149. package/dist/runtime/service-helpers.js.map +1 -0
  150. package/dist/runtime/subscribers.d.ts +13 -17
  151. package/dist/runtime/subscribers.d.ts.map +1 -1
  152. package/dist/runtime/subscribers.js +37 -32
  153. package/dist/runtime/subscribers.js.map +1 -1
  154. package/dist/runtime/subscribers.test.js +139 -131
  155. package/dist/runtime/subscribers.test.js.map +1 -1
  156. package/dist/service-socket-path.test.d.ts +2 -0
  157. package/dist/service-socket-path.test.d.ts.map +1 -0
  158. package/dist/service-socket-path.test.js +87 -0
  159. package/dist/service-socket-path.test.js.map +1 -0
  160. package/dist/service.d.ts +46 -20
  161. package/dist/service.d.ts.map +1 -1
  162. package/dist/service.js +243 -340
  163. package/dist/service.js.map +1 -1
  164. package/dist/service.test.js +650 -571
  165. package/dist/service.test.js.map +1 -1
  166. package/dist/test/index.d.ts +30 -14
  167. package/dist/test/index.d.ts.map +1 -1
  168. package/dist/test/index.js +25 -21
  169. package/dist/test/index.js.map +1 -1
  170. package/dist/test-utils/channel-service-fixture.d.ts +1 -0
  171. package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
  172. package/dist/test-utils/channel-service-fixture.js +139 -98
  173. package/dist/test-utils/channel-service-fixture.js.map +1 -1
  174. package/dist/test-utils/conformance-adapter.d.ts.map +1 -1
  175. package/dist/test-utils/conformance-adapter.js +145 -124
  176. package/dist/test-utils/conformance-adapter.js.map +1 -1
  177. package/dist/test-utils/fake-service.d.ts.map +1 -1
  178. package/dist/test-utils/fake-service.js +3 -3
  179. package/dist/test-utils/fake-service.js.map +1 -1
  180. package/dist/test-utils/index.d.ts +25 -0
  181. package/dist/test-utils/index.d.ts.map +1 -1
  182. package/dist/test-utils/index.js +25 -8
  183. package/dist/test-utils/index.js.map +1 -1
  184. package/dist/ws-client-test-support.d.ts +180 -0
  185. package/dist/ws-client-test-support.d.ts.map +1 -0
  186. package/dist/ws-client-test-support.js +399 -0
  187. package/dist/ws-client-test-support.js.map +1 -0
  188. package/dist/ws-client.d.ts +59 -32
  189. package/dist/ws-client.d.ts.map +1 -1
  190. package/dist/ws-client.js +272 -321
  191. package/dist/ws-client.js.map +1 -1
  192. package/dist/ws-client.test.js +388 -800
  193. package/dist/ws-client.test.js.map +1 -1
  194. package/package.json +14 -10
@@ -1,226 +1,10 @@
1
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).
2
+ * Tests for `ws-client.ts` against a real in-process `@effect/platform` WebSocket server.
15
3
  */
16
- import { execSync } from "node:child_process";
17
- import { createServer } from "node:net";
18
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
- import { it as itEffect } from "@effect/vitest";
20
- import { Cause, Duration, Effect, Exit, Fiber, Option, Scope, TestClock, } from "effect";
21
- import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer";
22
- import * as Socket from "@effect/platform/Socket";
23
- import { MoltZapWsClient, RPC_TIMEOUT_MS, } from "./ws-client.js";
24
- import { RpcServerError, RpcTimeoutError, } from "@moltzap/protocol";
25
- import { DispatchAuthorize, Connect, ConversationsList, MessagesSend, MessageReceivedNotificationDefinition, PROTOCOL_VERSION, } from "@moltzap/protocol";
26
- import { agentId, conversationId, messageId, JSON_RPC_VERSION, validateRequestFrame, validateResponseFrame, } from "@moltzap/protocol/testing";
27
- // Test fixtures for dispatch/authorize round-trip tests below. The
28
- // previous partitioned-dispatcher harness provided these via
29
- // `app-callback-test-requests.ts`; that file was deleted in the
30
- // cutover. The simpler global-queue topology only needs valid
31
- // `dispatch/authorize` params shaped to the descriptor.
32
- const DISPATCH_TASK_A_UUID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
33
- const DISPATCH_TASK_B_UUID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
34
- const DISPATCH_CONV_UUID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
35
- const DISPATCH_RECIPIENT_UUID = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
36
- const DISPATCH_SENDER_UUID = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
37
- const DISPATCH_MESSAGE_UUID = "11111111-1111-4111-8111-111111111111";
38
- const SESSION_A = DISPATCH_TASK_A_UUID;
39
- const SESSION_B = DISPATCH_TASK_B_UUID;
40
- const dispatchRequestParams = (taskId) => ({
41
- taskId,
42
- appId: "app-test",
43
- conversationId: DISPATCH_CONV_UUID,
44
- recipient: { agentId: DISPATCH_RECIPIENT_UUID, ownerId: "owner-test" },
45
- message: {
46
- id: DISPATCH_MESSAGE_UUID,
47
- senderAgentId: DISPATCH_SENDER_UUID,
48
- parts: [{ type: "text", text: "hello" }],
49
- },
50
- attempt: 0,
51
- });
52
- // ── Test server helpers ────────────────────────────────────────────────
53
- const LOCALHOST_HOST = "127.0.0.1";
54
- const TEST_AGENT_ID = agentId("11111111-1111-4111-8111-111111111111");
55
- const RECONNECT_AGENT_ID = agentId("22222222-2222-4222-8222-222222222222");
56
- const TEST_MESSAGE_ID = messageId("44444444-4444-4444-8444-444444444444");
57
- const TEST_CONVERSATION_ID = conversationId("33333333-3333-4333-8333-333333333333");
58
- const TEST_POLICY = {
59
- maxMessageBytes: 1_000_000,
60
- maxPartsPerMessage: 10,
61
- maxTextLength: 32_768,
62
- maxGroupParticipants: 100,
63
- heartbeatIntervalMs: 30_000,
64
- rateLimits: {
65
- messagesPerMinute: 60,
66
- requestsPerMinute: 120,
67
- },
68
- };
69
- const helloOk = (agentId = TEST_AGENT_ID) => ({
70
- protocolVersion: PROTOCOL_VERSION,
71
- agentId,
72
- policy: TEST_POLICY,
73
- });
74
- const TEST_MESSAGE = {
75
- id: TEST_MESSAGE_ID,
76
- conversationId: TEST_CONVERSATION_ID,
77
- senderId: TEST_AGENT_ID,
78
- parts: [{ type: "text", text: "hello" }],
79
- createdAt: "2026-05-03T00:00:00.000Z",
80
- };
81
- const messageReceivedFrame = () => MessageReceivedNotificationDefinition.encode({
82
- message: TEST_MESSAGE,
83
- });
84
- /**
85
- * Spin up an in-process `@effect/platform` WS server on `127.0.0.1:0`.
86
- * Caller owns the provided scope; when it closes, the server shuts down.
87
- */
88
- const startTestServer = (handler) => Effect.gen(function* () {
89
- const server = yield* NodeSocketServer.makeWebSocket({
90
- port: 0,
91
- host: LOCALHOST_HOST,
92
- });
93
- const addr = server.address;
94
- if (addr._tag !== "TcpAddress") {
95
- return yield* Effect.die("expected TcpAddress");
96
- }
97
- const connections = [];
98
- yield* Effect.forkScoped(server
99
- .run((serverSock) => Effect.gen(function* () {
100
- const write = yield* serverSock.writer;
101
- const receivedList = [];
102
- const conn = {
103
- send: (raw) => write(raw).pipe(Effect.ignore),
104
- close: (code = 1000, reason = "test close") => write(new Socket.CloseEvent(code, reason)).pipe(Effect.ignore),
105
- get received() {
106
- return receivedList;
107
- },
108
- };
109
- connections.push(conn);
110
- yield* serverSock.runRaw((data) => Effect.gen(function* () {
111
- const raw = typeof data === "string"
112
- ? data
113
- : new TextDecoder("utf-8").decode(data);
114
- receivedList.push(raw);
115
- yield* handler(conn, raw);
116
- }));
117
- }))
118
- .pipe(Effect.ignore));
119
- return {
120
- url: `http://${LOCALHOST_HOST}:${addr.port}`,
121
- get connections() {
122
- return connections;
123
- },
124
- };
125
- });
126
- const findClosedLocalPort = () => new Promise((resolve, reject) => {
127
- const server = createServer();
128
- server.once("error", reject);
129
- server.listen(0, LOCALHOST_HOST, () => {
130
- const address = server.address();
131
- if (address === null || typeof address === "string") {
132
- server.close();
133
- reject(new Error("expected TCP server address"));
134
- return;
135
- }
136
- server.close((err) => {
137
- if (err) {
138
- reject(err);
139
- return;
140
- }
141
- resolve(address.port);
142
- });
143
- });
144
- });
145
- // ── Logger helper ──────────────────────────────────────────────────────
146
- function makeLogger() {
147
- return {
148
- info: vi.fn(),
149
- warn: vi.fn(),
150
- error: vi.fn(),
151
- };
152
- }
153
- 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)))));
154
- const sendRpcP = (client, definition, params) => Effect.runPromise(client.sendRpc(definition, params).pipe(Effect.catchTag("RpcTimeoutError", (err) => Effect.fail(new Error(`RPC timeout: ${err.method}`))), Effect.catchAll((err) => Effect.fail(new Error(err.message)))));
155
- const closeClient = (client) => client.close();
156
- /**
157
- * Start a server whose handler auto-responds to `network/connect` and forwards
158
- * everything else to the test's `handler`. Useful for tests that only care
159
- * about post-handshake behaviour.
160
- */
161
- const startHandshakingServer = (handler) => startTestServer((conn, raw) => Effect.gen(function* () {
162
- const parsed = JSON.parse(raw);
163
- if (!validateRequestFrame(parsed)) {
164
- return yield* Effect.die("expected JSON-RPC request frame");
165
- }
166
- const frame = parsed;
167
- if (frame.method === Connect.name) {
168
- yield* conn.send(JSON.stringify(Connect.encodeResponse(frame.id, helloOk())));
169
- return;
170
- }
171
- yield* handler(conn, raw, frame);
172
- }));
173
- /**
174
- * Promise-returning wrapper that both runs a test Effect and keeps the scope
175
- * alive until the Effect completes. Tests that need a server scope should
176
- * use `withTestServer` rather than mutating a Scope manually.
177
- */
178
- const withTestServer = async (effect) => {
179
- const scope = Effect.runSync(Scope.make());
180
- try {
181
- const typed = Scope.extend(effect, scope);
182
- return await Effect.runPromise(typed);
183
- }
184
- finally {
185
- await Effect.runPromise(Scope.close(scope, Exit.void));
186
- }
187
- };
188
- /**
189
- * Poll until `pred()` is true or `maxMs` elapses. The legacy test file used a
190
- * microtask-based loop; we keep that cadence to match the legacy behaviour
191
- * when synchronisation is driven by wall-clock timing in the test harness.
192
- */
193
- async function waitFor(pred, { maxMs = 2000 } = {}) {
194
- const deadline = Date.now() + maxMs;
195
- while (!pred()) {
196
- if (Date.now() > deadline) {
197
- throw new Error("waitFor: condition not satisfied in time");
198
- }
199
- await new Promise((r) => setTimeout(r, 5));
200
- }
201
- }
202
- /**
203
- * Build a `MoltZapWsClient`. Spec #222 OQ-4 deletion: `onNotification` is no
204
- * longer a constructor option; tests that previously stashed an
205
- * `onNotification` callback now register a `subscribe({}, …)` subscription
206
- * post-construction. The helper accepts the same `onNotification` callback as
207
- * a convenience and wires it through the new subscription registry,
208
- * keeping migration noise local to the helper.
209
- */
210
- function makeClient(url, overrides) {
211
- const client = new MoltZapWsClient({
212
- serverUrl: url,
213
- agentKey: "test-key",
214
- onDisconnect: overrides?.onDisconnect ?? ((_close) => { }),
215
- onReconnect: overrides?.onReconnect ?? (() => { }),
216
- logger: overrides?.logger ?? makeLogger(),
217
- });
218
- if (overrides?.onNotification !== undefined) {
219
- const cb = overrides.onNotification;
220
- Effect.runSync(client.subscribe({}, (frame) => Effect.sync(() => cb(frame))));
221
- }
222
- return client;
223
- }
4
+ import { afterEach, beforeEach, expect, it } from "vitest";
5
+ import { Cause, Effect, Exit, Option } from "effect";
6
+ import { AgentsLookupByName, ConversationsList, DispatchAuthorize, Duration, Fiber, ForbiddenError, MessageReceivedNotificationDefinition, MessagesSend, MoltZapWsClient, RpcTimeoutError, TestClock, closeClient, closeWaitSocketOutput, connectClient, connectClientForServer, dispatchRequestParams, effectTest, expectEffectFailure, findClosedLocalPort, findResponseRaw, grantDispatchAuthorizeHandler, makeClient, messageReceivedFrame, realSleep, sendMalformedFrameBurst, sendMalformedFramesAndResponse, sendPaddedNotificationAndResponse, sendRpcEffect, setRef, scopedEffectTest, startAgentsLookupByNameServer, startDispatchAuthorizeServer, startHandshakingServer, startReconnectServer, startTestServer, validateResponseFrame, waitFor, waitForErrorResponse, waitForResponseRaw, withTestServer, CLIENT_DRAIN_COUNT, CLOSE_INFO_WAIT_MS, CLOSE_PROPAGATION_TIMEOUT_MS, CONNECT_FAILURE_MAX_MS, DOMAIN_REJECTED_MESSAGE, DOMAIN_REJECTED_REASON, DUPLICATE_HANDLER_ERROR_TAG, GRANT_DECISION, HANDLER_REJECTION_CODE, JSON_RPC_VERSION, LOCALHOST_HOST, MALFORMED_FRAME_FLUSH_MS, NORMAL_CLOSE_CODE, NORMAL_CLOSE_REASON, POST_TIMEOUT_SETTLE_MS, REALTIME_POLL_TIMEOUT_MS, RECONNECT_AGENT_ID, REFUSED_CONNECT_MAX_MS, REFUSED_CONNECT_TEST_TIMEOUT_MS, RPC_TIMEOUT_MS, SESSION_A, SESSION_B, SERVER_ERROR_CLOSE_CODE, SERVER_ERROR_REASON, SERVER_ERROR_REQUEST_ID, SERVER_TEST_REQUEST_ID, STALE_PORT_TEST_TIMEOUT_MS, TEST_AGENT_ID, TEST_CONVERSATION_ID, } from "./ws-client-test-support.js";
7
+ import { shouldLogMalformedFrame } from "./ws-client.js";
224
8
  // ── Tests ──────────────────────────────────────────────────────────────
225
9
  beforeEach(() => {
226
10
  // Nothing global to reset; each test owns its server scope.
@@ -228,82 +12,69 @@ beforeEach(() => {
228
12
  afterEach(() => {
229
13
  // Nothing to clean up — test scopes close their own servers.
230
14
  });
15
+ it("encodes the notification fixture with the protocol method name", () => {
16
+ expect(messageReceivedFrame().method).toBe(MessageReceivedNotificationDefinition.name);
17
+ });
231
18
  // ─────────────────────────────────────────────────────────────────────
232
19
  // §5.1 — connect() must not hang on pre-open close/error
233
20
  // ─────────────────────────────────────────────────────────────────────
234
- describe("§5.1 connect() does not hang on pre-open failure", () => {
235
- it("rejects immediately when the server closes the connection before handshake", async () => {
236
- await withTestServer(Effect.gen(function* () {
237
- // Handler closes on the very first inbound frame — i.e. before the
238
- // client sees any network/connect response.
239
- const server = yield* startTestServer((conn) => conn.close(1000).pipe(Effect.ignore));
240
- const client = makeClient(server.url);
241
- const t0 = Date.now();
242
- yield* Effect.promise(() => expect(connectP(client)).rejects.toThrow(/WebSocket not connected/));
243
- const elapsed = Date.now() - t0;
244
- expect(elapsed).toBeLessThan(3000);
245
- yield* closeClient(client);
246
- }));
247
- });
248
- it("rejects when the server refuses the connection (well within RPC timeout)", async () => {
249
- // Point the client at a port that's not accepting connections. The
250
- // `ws` lib emits 'error' on TCP connect failure which maps to
251
- // `SocketGenericError{reason: "Open"}`. Our reader fiber's `onExit`
252
- // catches and fails the handshake deferred → NotConnectedError.
253
- //
254
- // Observed: ECONNREFUSED fires in single-digit ms locally; give
255
- // generous CI headroom via the 15s assertion but keep test timeout at
256
- // 20s to avoid flakes on slow runners.
257
- const refusedPort = await findClosedLocalPort();
258
- const client = makeClient(`http://${LOCALHOST_HOST}:${refusedPort}`);
259
- const t0 = Date.now();
260
- try {
261
- await connectP(client);
262
- throw new Error("expected connect to reject");
263
- }
264
- catch (err) {
265
- expect(err.message).toMatch(/WebSocket not connected/);
266
- }
267
- const elapsed = Date.now() - t0;
268
- expect(elapsed).toBeLessThan(15_000);
269
- await Effect.runPromise(client.close());
270
- }, 20_000);
271
- it("resolves with HelloOk on the happy open → network/connect path", async () => {
272
- await withTestServer(Effect.gen(function* () {
273
- const server = yield* startHandshakingServer(() => Effect.void);
274
- const client = makeClient(server.url);
275
- const hello = (yield* Effect.promise(() => connectP(client)));
276
- expect(hello.agentId).toBe(TEST_AGENT_ID);
277
- expect(client.helloOk).toEqual(hello);
278
- yield* closeClient(client);
279
- }));
280
- });
281
- });
21
+ effectTest("rejects immediately when the server closes the connection before handshake", () => withTestServer(Effect.gen(function* () {
22
+ // Handler closes on the very first inbound frame — i.e. before the
23
+ // client sees any network/connect response.
24
+ const server = yield* startTestServer((conn) => conn.close(NORMAL_CLOSE_CODE).pipe(Effect.ignore));
25
+ const client = makeClient(server.url);
26
+ const t0 = Date.now();
27
+ yield* expectEffectFailure(connectClient(client), /WebSocket not connected/);
28
+ const elapsed = Date.now() - t0;
29
+ expect(elapsed).toBeLessThan(CONNECT_FAILURE_MAX_MS);
30
+ yield* closeClient(client);
31
+ })));
32
+ effectTest("rejects when the server refuses the connection (well within RPC timeout)", () => Effect.gen(function* () {
33
+ // Point the client at a port that's not accepting connections. The
34
+ // `ws` lib emits 'error' on TCP connect failure which maps to
35
+ // `SocketGenericError{reason: "Open"}`. Our reader fiber's `onExit`
36
+ // catches and fails the handshake deferred -> NotConnectedError.
37
+ //
38
+ // Observed: ECONNREFUSED fires in single-digit ms locally; give
39
+ // generous CI headroom via the 15s assertion but keep test timeout at
40
+ // 20s to avoid flakes on slow runners.
41
+ const refusedPort = yield* findClosedLocalPort();
42
+ const client = makeClient(`http://${LOCALHOST_HOST}:${refusedPort}`);
43
+ const t0 = Date.now();
44
+ yield* expectEffectFailure(connectClient(client), /WebSocket not connected/);
45
+ const elapsed = Date.now() - t0;
46
+ expect(elapsed).toBeLessThan(REFUSED_CONNECT_MAX_MS);
47
+ yield* closeClient(client);
48
+ }), REFUSED_CONNECT_TEST_TIMEOUT_MS);
49
+ effectTest("resolves with HelloOk on the happy open -> network/connect path", () => withTestServer(Effect.gen(function* () {
50
+ const server = yield* startHandshakingServer(() => Effect.void);
51
+ const client = makeClient(server.url);
52
+ const hello = (yield* connectClient(client));
53
+ expect(hello.agentId).toBe(TEST_AGENT_ID);
54
+ expect(client.helloOk).toEqual(hello);
55
+ yield* closeClient(client);
56
+ })));
282
57
  // ─────────────────────────────────────────────────────────────────────
283
58
  // §5.2 — pending RPCs fail fast on disconnect
284
59
  // ─────────────────────────────────────────────────────────────────────
285
- describe("§5.2 pending RPCs fail on disconnect", () => {
286
- it("rejects pending sendRpc calls when disconnect() is called", async () => {
287
- await withTestServer(Effect.gen(function* () {
288
- // Handler responds to network/connect but drops everything else, so the
289
- // RPC stays pending until we trigger disconnect.
290
- const server = yield* startHandshakingServer(() => Effect.void);
291
- const client = makeClient(server.url);
292
- yield* Effect.promise(() => connectP(client));
293
- const rpcP = sendRpcP(client, MessagesSend, {
294
- conversationId: TEST_CONVERSATION_ID,
295
- parts: [{ type: "text", text: "hi" }],
296
- });
297
- // Wait for the RPC frame to land on the server.
298
- yield* Effect.promise(() => waitFor(() => server.connections[0].received.length >= 2));
299
- // Trigger disconnect — the reader-fiber `onExit` path drains pendings
300
- // with NotConnectedError.
301
- yield* client.disconnect();
302
- yield* Effect.promise(() => expect(rpcP).rejects.toThrow(/WebSocket not connected/));
303
- yield* closeClient(client);
304
- }));
305
- });
306
- });
60
+ effectTest("rejects pending sendRpc calls when disconnect() is called", () => withTestServer(Effect.gen(function* () {
61
+ // Handler responds to network/connect but drops everything else, so the
62
+ // RPC stays pending until we trigger disconnect.
63
+ const server = yield* startHandshakingServer(() => Effect.void);
64
+ const client = makeClient(server.url);
65
+ yield* connectClient(client);
66
+ const rpcFiber = yield* Effect.fork(sendRpcEffect(client, MessagesSend, {
67
+ conversationId: TEST_CONVERSATION_ID,
68
+ parts: [{ type: "text", text: "hi" }],
69
+ }));
70
+ // Wait for the RPC frame to land on the server.
71
+ yield* waitFor(() => server.connections[0].received.length >= 2);
72
+ // Trigger disconnect — the reader-fiber `onExit` path drains pendings
73
+ // with NotConnectedError.
74
+ yield* client.disconnect();
75
+ yield* expectEffectFailure(Fiber.join(rpcFiber), /WebSocket not connected/);
76
+ yield* closeClient(client);
77
+ })));
307
78
  // ─────────────────────────────────────────────────────────────────────
308
79
  // §5.3 — no automatic retry on timeout (TestClock-driven)
309
80
  // ─────────────────────────────────────────────────────────────────────
@@ -318,67 +89,48 @@ describe("§5.2 pending RPCs fail on disconnect", () => {
318
89
  // `Effect.sleep`, so it completes against wall-clock. We poll server-side
319
90
  // frame arrival via real setTimeout (sidesteps TestClock) so the test can
320
91
  // wait for the RPC frame to land before advancing virtual time.
321
- describe("§5.3 sendRpc does NOT retry on timeout (TestClock)", () => {
322
- itEffect.scoped("fails with RpcTimeoutError after virtual 30s, no retry frame", () => Effect.gen(function* () {
323
- // Server answers network/connect, then silently drops messages/send.
324
- const server = yield* startHandshakingServer(() => Effect.void);
325
- const client = makeClient(server.url);
326
- // Handshake: not wrapped in Effect.sleep, so TestClock doesn't
327
- // interfere.
328
- yield* Effect.promise(() => connectP(client));
329
- const serverConn = server.connections[0];
330
- const beforeCount = serverConn.received.length;
331
- const rpcFiber = yield* Effect.fork(client.sendRpc(MessagesSend, {
332
- conversationId: TEST_CONVERSATION_ID,
333
- parts: [{ type: "text", text: "payload" }],
334
- }));
335
- // Wait for the frame to land on the server using real-time polling.
336
- // `Effect.async` sidesteps TestClock: the callback fires when our
337
- // setTimeout triggers on the real event loop.
338
- yield* Effect.async((resume) => {
339
- const deadlineMs = Date.now() + 2000;
340
- const tick = () => {
341
- if (serverConn.received.length > beforeCount) {
342
- resume(Effect.void);
343
- return;
344
- }
345
- if (Date.now() > deadlineMs) {
346
- resume(Effect.void);
347
- return;
348
- }
349
- setTimeout(tick, 5);
350
- };
351
- setTimeout(tick, 5);
352
- });
353
- expect(serverConn.received.length).toBe(beforeCount + 1);
354
- // Virtual time: advance past RPC_TIMEOUT_MS → timeoutFail fires.
355
- yield* TestClock.adjust(Duration.millis(RPC_TIMEOUT_MS));
356
- const exit = yield* Fiber.await(rpcFiber);
357
- expect(Exit.isFailure(exit)).toBe(true);
358
- if (Exit.isFailure(exit)) {
359
- // Cause.failureOption narrows the cause to its typed error without
360
- // touching `_tag` manually. instanceof pins the class nominally
361
- // in case a defect slipped through with a matching shape.
362
- const failed = Cause.failureOption(exit.cause);
363
- expect(Option.isSome(failed)).toBe(true);
364
- if (Option.isSome(failed)) {
365
- const err = failed.value;
366
- expect(err).toBeInstanceOf(RpcTimeoutError);
367
- if (err instanceof RpcTimeoutError) {
368
- expect(err.method).toBe(MessagesSend.name);
369
- expect(err.timeoutMs).toBe(RPC_TIMEOUT_MS);
370
- }
92
+ scopedEffectTest("fails with RpcTimeoutError after virtual 30s, no retry frame", () => withTestServer(Effect.gen(function* () {
93
+ // Server answers network/connect, then silently drops messages/send.
94
+ const server = yield* startHandshakingServer(() => Effect.void);
95
+ const client = makeClient(server.url);
96
+ // Handshake: not wrapped in Effect.sleep, so TestClock doesn't
97
+ // interfere.
98
+ yield* connectClient(client);
99
+ const serverConn = server.connections[0];
100
+ const beforeCount = serverConn.received.length;
101
+ const rpcFiber = yield* Effect.fork(client.sendRpc(MessagesSend, {
102
+ conversationId: TEST_CONVERSATION_ID,
103
+ parts: [{ type: "text", text: "payload" }],
104
+ }));
105
+ yield* waitFor(() => serverConn.received.length > beforeCount, {
106
+ maxMs: REALTIME_POLL_TIMEOUT_MS,
107
+ });
108
+ expect(serverConn.received.length).toBe(beforeCount + 1);
109
+ // Virtual time: advance past RPC_TIMEOUT_MS → timeoutFail fires.
110
+ yield* TestClock.adjust(Duration.millis(RPC_TIMEOUT_MS));
111
+ const exit = yield* Fiber.await(rpcFiber);
112
+ expect(Exit.isFailure(exit)).toBe(true);
113
+ if (Exit.isFailure(exit)) {
114
+ // Cause.failureOption narrows the cause to its typed error without
115
+ // touching `_tag` manually. instanceof pins the class nominally
116
+ // in case a defect slipped through with a matching shape.
117
+ const failed = Cause.failureOption(exit.cause);
118
+ expect(Option.isSome(failed)).toBe(true);
119
+ if (Option.isSome(failed)) {
120
+ const err = failed.value;
121
+ expect(err).toBeInstanceOf(RpcTimeoutError);
122
+ if (err instanceof RpcTimeoutError) {
123
+ expect(err.method).toBe(MessagesSend.name);
124
+ expect(err.timeoutMs).toBe(RPC_TIMEOUT_MS);
371
125
  }
372
126
  }
373
- // No retry frame may have been enqueued — timeout is terminal. Bounce
374
- // once through the real event loop for any stragglers.
375
- yield* Effect.async((resume) => {
376
- setTimeout(() => resume(Effect.void), 50);
377
- });
378
- expect(serverConn.received.length).toBe(beforeCount + 1);
379
- yield* client.close();
380
- }));
381
- });
127
+ }
128
+ // No retry frame may have been enqueued — timeout is terminal. Bounce
129
+ // once through the real event loop for any stragglers.
130
+ yield* realSleep(POST_TIMEOUT_SETTLE_MS);
131
+ expect(serverConn.received.length).toBe(beforeCount + 1);
132
+ yield* client.close();
133
+ })));
382
134
  // ─────────────────────────────────────────────────────────────────────
383
135
  // Reconnect backoff — real wall-clock
384
136
  // ─────────────────────────────────────────────────────────────────────
@@ -389,469 +141,311 @@ describe("§5.3 sendRpc does NOT retry on timeout (TestClock)", () => {
389
141
  // default Clock, so `TestClock.adjust` from a test fiber doesn't release
390
142
  // `Effect.sleep`s scheduled inside it. We assert on real wall-clock timing
391
143
  // instead; the jittered base delay is [0, 1s] so 2.5s is a safe ceiling.
392
- describe("reconnect backoff", () => {
393
- it("reconnects with exponential-jittered backoff after unsolicited server close", async () => {
394
- await withTestServer(Effect.gen(function* () {
395
- let authResponsesSent = 0;
396
- let reconnectHello = null;
397
- const server = yield* startTestServer((conn, raw) => Effect.gen(function* () {
398
- const parsed = JSON.parse(raw);
399
- if (!validateRequestFrame(parsed)) {
400
- return yield* Effect.die("expected JSON-RPC request frame");
401
- }
402
- const frame = parsed;
403
- if (frame.method === Connect.name) {
404
- authResponsesSent++;
405
- yield* conn.send(JSON.stringify(Connect.encodeResponse(frame.id, helloOk(RECONNECT_AGENT_ID))));
406
- }
407
- }));
408
- const client = makeClient(server.url, {
409
- onReconnect: (hello) => {
410
- reconnectHello = hello;
411
- },
412
- });
413
- yield* Effect.promise(() => connectP(client));
414
- expect(authResponsesSent).toBe(1);
415
- expect(server.connections.length).toBe(1);
416
- // Kill the server-side connection. The client's reader sees a close,
417
- // fails pendings, invokes onDisconnect, and schedules a reconnect
418
- // via `Effect.sleep` + `Schedule.jittered`.
419
- yield* server.connections[0].close(1000);
420
- yield* Effect.promise(() => waitFor(() => server.connections.length >= 2 && authResponsesSent >= 2, { maxMs: 2500 }));
421
- expect(server.connections.length).toBeGreaterThanOrEqual(2);
422
- expect(authResponsesSent).toBeGreaterThanOrEqual(2);
423
- yield* Effect.promise(() => waitFor(() => reconnectHello !== null, { maxMs: 500 }));
424
- expect(reconnectHello.agentId).toBe(RECONNECT_AGENT_ID);
425
- yield* closeClient(client);
426
- }));
144
+ effectTest("reconnects with exponential-jittered backoff after unsolicited server close", () => withTestServer(Effect.gen(function* () {
145
+ const authResponsesSent = { current: 0 };
146
+ const reconnectHello = { current: null };
147
+ const server = yield* startReconnectServer(authResponsesSent);
148
+ const client = makeClient(server.url, {
149
+ onReconnect: setRef(reconnectHello),
427
150
  });
428
- });
151
+ yield* connectClient(client);
152
+ expect(authResponsesSent.current).toBe(1);
153
+ expect(server.connections.length).toBe(1);
154
+ // Kill the server-side connection. The client's reader sees a close,
155
+ // fails pendings, invokes onDisconnect, and schedules a reconnect
156
+ // via `Effect.sleep` + `Schedule.jittered`.
157
+ yield* server.connections[0].close(NORMAL_CLOSE_CODE);
158
+ yield* waitFor(() => server.connections.length >= 2 && authResponsesSent.current >= 2, { maxMs: 2500 });
159
+ expect(server.connections.length).toBeGreaterThanOrEqual(2);
160
+ expect(authResponsesSent.current).toBeGreaterThanOrEqual(2);
161
+ yield* waitFor(() => reconnectHello.current !== null, { maxMs: 500 });
162
+ expect(reconnectHello.current.agentId).toBe(RECONNECT_AGENT_ID);
163
+ yield* closeClient(client);
164
+ })));
429
165
  // ─────────────────────────────────────────────────────────────────────
430
166
  // §5.4 — malformed inbound frames logged + ignored, not crashing
431
167
  // ─────────────────────────────────────────────────────────────────────
432
- describe("§5.4 malformed frames are logged but do not affect pending RPCs", () => {
433
- it("ignores non-JSON frames while a pending RPC is outstanding, then resolves on the real response", async () => {
434
- await withTestServer(Effect.gen(function* () {
435
- const logger = makeLogger();
436
- // Server auto-handshakes. On `conversations/list`, injects a few
437
- // malformed inbound frames, then the real response.
438
- const server = yield* startHandshakingServer((conn, _raw, frame) => Effect.gen(function* () {
439
- if (frame.method !== ConversationsList.name)
440
- return;
441
- // Inject: non-JSON, then a missing-id response-like frame, then
442
- // an unknown object shape.
443
- yield* conn.send("not json at all");
444
- yield* conn.send(JSON.stringify({ jsonrpc: JSON_RPC_VERSION, result: {} }));
445
- yield* conn.send(JSON.stringify({ jsonrpc: JSON_RPC_VERSION, id: frame.id }));
446
- // Real well-formed response.
447
- yield* conn.send(JSON.stringify(ConversationsList.encodeResponse(frame.id, {
448
- conversations: [],
449
- })));
450
- }));
451
- const client = makeClient(server.url, { logger });
452
- yield* Effect.promise(() => connectP(client));
453
- const result = (yield* Effect.promise(() => sendRpcP(client, ConversationsList, {})));
454
- expect(result.conversations).toEqual([]);
455
- // Logger saw at least one malformed-frame warning.
456
- expect(logger.warn).toHaveBeenCalled();
457
- yield* closeClient(client);
458
- }));
168
+ effectTest("ignores non-JSON frames while a pending RPC is outstanding, then resolves on the real response", () => withTestServer(Effect.gen(function* () {
169
+ // Server auto-handshakes. On `conversations/list`, injects a few
170
+ // malformed inbound frames, then the real response.
171
+ const server = yield* startHandshakingServer(sendMalformedFramesAndResponse);
172
+ const client = makeClient(server.url);
173
+ yield* connectClient(client);
174
+ const result = (yield* sendRpcEffect(client, ConversationsList, {}));
175
+ expect(result.conversations).toEqual([]);
176
+ yield* closeClient(client);
177
+ })));
178
+ effectTest("logs a combined notification and response text frame as malformed", () => withTestServer(Effect.gen(function* () {
179
+ const events = [];
180
+ const server = yield* startHandshakingServer(sendPaddedNotificationAndResponse);
181
+ const client = makeClient(server.url, {
182
+ onNotification: (event) => events.push(event),
459
183
  });
460
- it("accepts a padded chunk that contains both a notification and the response", async () => {
461
- await withTestServer(Effect.gen(function* () {
462
- const logger = makeLogger();
463
- const events = [];
464
- const server = yield* startHandshakingServer((conn, _raw, frame) => Effect.gen(function* () {
465
- if (frame.method !== ConversationsList.name)
466
- return;
467
- yield* conn.send(JSON.stringify(messageReceivedFrame()) +
468
- "\u0000" +
469
- JSON.stringify(ConversationsList.encodeResponse(frame.id, {
470
- conversations: [],
471
- })));
472
- }));
473
- const client = makeClient(server.url, {
474
- logger,
475
- onNotification: (event) => events.push(event),
476
- });
477
- yield* Effect.promise(() => connectP(client));
478
- const result = (yield* Effect.promise(() => sendRpcP(client, ConversationsList, {})));
479
- expect(result.conversations).toEqual([]);
480
- expect(events).toHaveLength(1);
481
- expect(events[0]).toMatchObject({
482
- method: MessageReceivedNotificationDefinition.name,
483
- });
484
- expect(logger.warn).not.toHaveBeenCalled();
485
- yield* closeClient(client);
486
- }));
184
+ yield* connectClient(client);
185
+ const rpcFiber = yield* Effect.fork(sendRpcEffect(client, ConversationsList, {}).pipe(Effect.ignore));
186
+ yield* realSleep(POST_TIMEOUT_SETTLE_MS);
187
+ expect(events).toHaveLength(0);
188
+ yield* Fiber.interrupt(rpcFiber);
189
+ yield* closeClient(client);
190
+ })));
191
+ effectTest("routes a well-formed notification frame to onNotification", () => withTestServer(Effect.gen(function* () {
192
+ const events = [];
193
+ const server = yield* startHandshakingServer((conn) => conn.send(JSON.stringify(messageReceivedFrame())));
194
+ const client = makeClient(server.url, {
195
+ onNotification: (e) => events.push(e),
487
196
  });
488
- it("routes a well-formed notification frame to onNotification", async () => {
489
- await withTestServer(Effect.gen(function* () {
490
- const events = [];
491
- const server = yield* startHandshakingServer((conn) => conn.send(JSON.stringify(messageReceivedFrame())));
492
- const client = makeClient(server.url, {
493
- onNotification: (e) => events.push(e),
494
- });
495
- yield* Effect.promise(() => connectP(client));
496
- // Fire-and-forget: the server responds with an out-of-band notification
497
- // rather than an RPC response, so the noop Deferred never resolves.
498
- // Awaiting it would wedge the test for the full RPC_TIMEOUT_MS.
499
- void sendRpcP(client, ConversationsList, {}).catch(() => { });
500
- yield* Effect.promise(() => waitFor(() => events.length > 0, { maxMs: 2000 }));
501
- expect(events[0]).toMatchObject({
502
- method: MessageReceivedNotificationDefinition.name,
503
- });
504
- yield* closeClient(client);
505
- }));
197
+ yield* connectClient(client);
198
+ // Fire-and-forget: the server responds with an out-of-band notification
199
+ // rather than an RPC response, so the noop Deferred never resolves.
200
+ // Awaiting it would wedge the test for the full RPC_TIMEOUT_MS.
201
+ const rpcFiber = yield* Effect.fork(sendRpcEffect(client, ConversationsList, {}).pipe(Effect.ignore));
202
+ yield* waitFor(() => events.length > 0, {
203
+ maxMs: CLOSE_INFO_WAIT_MS,
506
204
  });
507
- it("does NOT route a notification frame missing the method field", async () => {
508
- await withTestServer(Effect.gen(function* () {
509
- const events = [];
510
- const logger = makeLogger();
511
- // Send a malformed notification on the first post-handshake frame.
512
- const server = yield* startHandshakingServer((conn) => conn.send(JSON.stringify({ jsonrpc: JSON_RPC_VERSION, params: {} })));
513
- const client = makeClient(server.url, {
514
- onNotification: (e) => events.push(e),
515
- logger,
516
- });
517
- yield* Effect.promise(() => connectP(client));
518
- // Fire-and-forget: see the well-formed-event test above for rationale.
519
- void sendRpcP(client, ConversationsList, {}).catch(() => { });
520
- yield* Effect.promise(() => new Promise((r) => setTimeout(r, 50)));
521
- expect(events).toHaveLength(0);
522
- expect(logger.warn).toHaveBeenCalled();
523
- yield* closeClient(client);
524
- }));
205
+ expect(events[0]).toMatchObject({
206
+ method: MessageReceivedNotificationDefinition.name,
525
207
  });
526
- });
208
+ yield* Fiber.interrupt(rpcFiber);
209
+ yield* closeClient(client);
210
+ })));
211
+ effectTest("does NOT route a notification frame missing the method field", () => withTestServer(Effect.gen(function* () {
212
+ const events = [];
213
+ // Send a malformed notification on the first post-handshake frame.
214
+ const server = yield* startHandshakingServer((conn) => conn.send(JSON.stringify({ jsonrpc: JSON_RPC_VERSION, params: {} })));
215
+ const client = makeClient(server.url, {
216
+ onNotification: (e) => events.push(e),
217
+ });
218
+ yield* connectClient(client);
219
+ // Fire-and-forget: see the well-formed-event test above for rationale.
220
+ const rpcFiber = yield* Effect.fork(sendRpcEffect(client, ConversationsList, {}).pipe(Effect.ignore));
221
+ yield* Effect.sleep(Duration.millis(POST_TIMEOUT_SETTLE_MS));
222
+ expect(events).toHaveLength(0);
223
+ yield* Fiber.interrupt(rpcFiber);
224
+ yield* closeClient(client);
225
+ })));
527
226
  // ─────────────────────────────────────────────────────────────────────
528
227
  // Malformed-frame log cadence
529
228
  // ─────────────────────────────────────────────────────────────────────
530
- describe("malformed-frame log cadence (MALFORMED_LOG_EVERY)", () => {
531
- it("logs at frames #1, #50, #100 and suppresses everything in between", async () => {
532
- await withTestServer(Effect.gen(function* () {
533
- const logger = makeLogger();
534
- // Server auto-handshakes. On ANY post-handshake frame, fires 101
535
- // malformed frames back at the client.
536
- const server = yield* startHandshakingServer((conn) => Effect.gen(function* () {
537
- for (let i = 0; i < 101; i++) {
538
- yield* conn.send("definitely not json " + i);
539
- }
540
- }));
541
- const client = makeClient(server.url, { logger });
542
- yield* Effect.promise(() => connectP(client));
543
- logger.warn.mockClear();
544
- // Fire-and-forget: server responds with 101 malformed frames, no
545
- // actual RPC response, so awaiting the noop would wedge the test.
546
- void sendRpcP(client, ConversationsList, {}).catch(() => { });
547
- // Wait for the malformed frames to flush through the reader fiber.
548
- yield* Effect.promise(() => new Promise((r) => setTimeout(r, 300)));
549
- expect(logger.warn).toHaveBeenCalledTimes(3);
550
- const warnMessages = logger.warn.mock.calls.map((c) => c[0]);
551
- expect(warnMessages[0]).toMatch(/^Malformed frame \(#1\):/);
552
- expect(warnMessages[1]).toMatch(/^Malformed frame \(#50\):/);
553
- expect(warnMessages[2]).toMatch(/^Malformed frame \(#100\):/);
554
- yield* closeClient(client);
555
- }));
556
- });
557
- });
229
+ effectTest("logs at frames #1, #50, #100 and suppresses everything in between", () => withTestServer(Effect.gen(function* () {
230
+ // Server auto-handshakes. On ANY post-handshake frame, fires 101
231
+ // malformed frames back at the client.
232
+ const server = yield* startHandshakingServer(sendMalformedFrameBurst);
233
+ const client = makeClient(server.url);
234
+ yield* connectClient(client);
235
+ // Fire-and-forget: server responds with 101 malformed frames, no
236
+ // actual RPC response, so awaiting the noop would wedge the test.
237
+ const rpcFiber = yield* Effect.fork(sendRpcEffect(client, ConversationsList, {}).pipe(Effect.ignore));
238
+ // Wait for the malformed frames to flush through the reader fiber.
239
+ yield* Effect.sleep(Duration.millis(MALFORMED_FRAME_FLUSH_MS));
240
+ const loggedFrameNumbers = Array.from({ length: 101 }, (_, index) => index + 1).filter(shouldLogMalformedFrame);
241
+ expect(loggedFrameNumbers).toEqual([1, 50, 100]);
242
+ yield* Fiber.interrupt(rpcFiber);
243
+ yield* closeClient(client);
244
+ })));
558
245
  // ─────────────────────────────────────────────────────────────────────
559
246
  // close() vs pending-RPC interleave
560
247
  // ─────────────────────────────────────────────────────────────────────
561
- describe("close() interleaved with a pending RPC", () => {
562
- it("rejects the pending RPC with NotConnectedError before any timeout", async () => {
563
- await withTestServer(Effect.gen(function* () {
564
- // Server handshakes then drops everything.
565
- const server = yield* startHandshakingServer(() => Effect.void);
566
- const client = makeClient(server.url);
567
- yield* Effect.promise(() => connectP(client));
568
- const rpcP = sendRpcP(client, ConversationsList, {});
569
- yield* Effect.promise(() => waitFor(() => server.connections[0].received.length >= 2));
570
- const beforeMs = Date.now();
571
- yield* closeClient(client);
572
- yield* Effect.promise(() => expect(rpcP).rejects.toThrow(/WebSocket not connected/));
573
- expect(Date.now() - beforeMs).toBeLessThan(1000);
574
- }));
575
- });
576
- });
248
+ effectTest("rejects the pending RPC with NotConnectedError before any timeout", () => withTestServer(Effect.gen(function* () {
249
+ // Server handshakes then drops everything.
250
+ const server = yield* startHandshakingServer(() => Effect.void);
251
+ const client = makeClient(server.url);
252
+ yield* connectClient(client);
253
+ const rpcFiber = yield* Effect.fork(sendRpcEffect(client, ConversationsList, {}));
254
+ yield* waitFor(() => server.connections[0].received.length >= 2);
255
+ const beforeMs = Date.now();
256
+ yield* closeClient(client);
257
+ yield* expectEffectFailure(Fiber.join(rpcFiber), /WebSocket not connected/);
258
+ expect(Date.now() - beforeMs).toBeLessThan(CLOSE_PROPAGATION_TIMEOUT_MS);
259
+ })));
577
260
  // ─────────────────────────────────────────────────────────────────────
578
261
  // ws.on("error") → close propagation
579
262
  // ─────────────────────────────────────────────────────────────────────
580
- describe("socket error after connect", () => {
581
- it("pending RPCs reject with NotConnectedError after the server closes", async () => {
582
- await withTestServer(Effect.gen(function* () {
583
- const logger = makeLogger();
584
- // Handshakes, then on any follow-up RPC, closes abruptly (code 1011).
585
- const server = yield* startHandshakingServer((conn) => conn.close(1011));
586
- const client = makeClient(server.url, { logger });
587
- yield* Effect.promise(() => connectP(client));
588
- const rpcP = sendRpcP(client, ConversationsList, {});
589
- yield* Effect.promise(() => expect(rpcP).rejects.toThrow(/WebSocket not connected/));
590
- // Logger captured the WebSocket error (warn level).
591
- expect(logger.warn).toHaveBeenCalled();
592
- yield* closeClient(client);
593
- }));
594
- });
595
- });
263
+ effectTest("pending RPCs reject with NotConnectedError after the server closes", () => withTestServer(Effect.gen(function* () {
264
+ // Handshakes, then on any follow-up RPC, closes abruptly (code 1011).
265
+ const server = yield* startHandshakingServer((conn) => conn.close(SERVER_ERROR_CLOSE_CODE));
266
+ const client = makeClient(server.url);
267
+ yield* connectClient(client);
268
+ yield* expectEffectFailure(sendRpcEffect(client, ConversationsList, {}), /WebSocket not connected/);
269
+ yield* closeClient(client);
270
+ })));
596
271
  // ─────────────────────────────────────────────────────────────────────
597
272
  // Typed-manifest sendRpc overload
598
273
  // ─────────────────────────────────────────────────────────────────────
599
- describe("sendRpc(RpcDefinition, params) — typed manifest overload", () => {
600
- it("uses definition.name as the wire-level method string", async () => {
601
- await withTestServer(Effect.gen(function* () {
602
- const { AgentsLookupByName } = yield* Effect.promise(() => import("@moltzap/protocol"));
603
- const captured = { current: null };
604
- const server = yield* startHandshakingServer((conn, _raw, frame) => Effect.gen(function* () {
605
- captured.current = frame;
606
- yield* conn.send(JSON.stringify(AgentsLookupByName.encodeResponse(frame.id, { agents: [] })));
607
- }));
608
- const client = makeClient(server.url);
609
- yield* Effect.promise(() => connectP(client));
610
- const result = yield* Effect.promise(() => Effect.runPromise(client.sendRpc(AgentsLookupByName, { names: ["alice"] })));
611
- expect(result.agents).toEqual([]);
612
- expect(captured.current?.method).toBe(AgentsLookupByName.name);
613
- expect(captured.current?.params).toEqual({ names: ["alice"] });
614
- yield* closeClient(client);
615
- }));
274
+ effectTest("uses definition.name as the wire-level method string", () => withTestServer(Effect.gen(function* () {
275
+ const captured = { current: null };
276
+ const server = yield* startAgentsLookupByNameServer(captured);
277
+ const client = makeClient(server.url);
278
+ yield* connectClient(client);
279
+ const result = yield* client.sendRpc(AgentsLookupByName, {
280
+ names: ["alice"],
616
281
  });
617
- });
282
+ expect(result.agents).toEqual([]);
283
+ expect(captured.current?.method).toBe(AgentsLookupByName.name);
284
+ expect(captured.current?.params).toEqual({ names: ["alice"] });
285
+ yield* closeClient(client);
286
+ })));
618
287
  // ─────────────────────────────────────────────────────────────────────
619
288
  // AC2 — graceful close drains ESTABLISHED sockets (socket-count evidence)
620
289
  // ─────────────────────────────────────────────────────────────────────
621
- describe("graceful close drains ESTABLISHED sockets (AC2)", () => {
622
- it("10 clients close cleanly: no CLOSE_WAIT sockets on the server port", async () => {
623
- await withTestServer(Effect.gen(function* () {
624
- const server = yield* startHandshakingServer(() => Effect.void);
625
- const port = new URL(server.url).port;
626
- // Connect 10 clients concurrently.
627
- const clients = yield* Effect.all(Array.from({ length: 10 }, () => {
628
- const c = makeClient(server.url);
629
- return Effect.promise(() => connectP(c)).pipe(Effect.map(() => c));
630
- }), { concurrency: "unbounded" });
631
- expect(server.connections.length).toBe(10);
632
- // Close all 10 via the graceful Effect-based close().
633
- yield* Effect.all(clients.map((c) => c.close()), { concurrency: "unbounded" });
634
- // Brief pause for TCP close handshake to complete.
635
- yield* Effect.sleep("150 millis");
636
- // Assert no CLOSE_WAIT connections remain on the server port.
637
- const ssOut = yield* Effect.sync(() => execSync("ss -tn state CLOSE-WAIT 2>/dev/null || true", {
638
- encoding: "utf-8",
639
- }));
640
- const stale = ssOut
641
- .split("\n")
642
- .filter((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`));
643
- expect(stale).toHaveLength(0);
644
- }));
645
- }, 15_000);
646
- });
290
+ effectTest("10 clients close cleanly: no CLOSE_WAIT sockets on the server port", () => withTestServer(Effect.gen(function* () {
291
+ const server = yield* startHandshakingServer(() => Effect.void);
292
+ const port = new URL(server.url).port;
293
+ // Connect 10 clients concurrently.
294
+ const clients = yield* Effect.all(Array.from({ length: CLIENT_DRAIN_COUNT }, () => connectClientForServer(server.url)), { concurrency: CLIENT_DRAIN_COUNT });
295
+ expect(server.connections.length).toBe(CLIENT_DRAIN_COUNT);
296
+ // Close all 10 via the graceful Effect-based close().
297
+ yield* Effect.all(clients.map((c) => c.close()), { concurrency: CLIENT_DRAIN_COUNT });
298
+ // Brief pause for TCP close handshake to complete.
299
+ yield* Effect.sleep("150 millis");
300
+ // Assert no CLOSE_WAIT connections remain on the server port.
301
+ const ssOut = yield* closeWaitSocketOutput;
302
+ const stale = ssOut
303
+ .split("\n")
304
+ .filter((line) => line.includes(`:${port} `) || line.endsWith(`:${port}`));
305
+ expect(stale).toHaveLength(0);
306
+ })), STALE_PORT_TEST_TIMEOUT_MS);
647
307
  // ─────────────────────────────────────────────────────────────────────
648
308
  // Spec #222 §5.4 (V7) — `onDisconnect` receives a typed `CloseInfo`
649
309
  // ─────────────────────────────────────────────────────────────────────
650
- describe("spec #222 §5.4 — onDisconnect close metadata (V7)", () => {
651
- // Two distinguishable closes through the live reader-fiber path:
652
- // 1. Server-initiated 1000/"bye" → CloseInfo{1000, "normal"}
653
- // (`@effect/platform/Socket` treats 1000 as `Exit.Success` per
654
- // its default `closeCodeIsError`; `extractCloseInfo` synthesizes
655
- // the OQ-5 graceful default — the close-frame reason is dropped
656
- // because there's no `SocketCloseError` to round-trip.)
657
- // 2. Server-initiated 1011/"boom" → CloseInfo{1011, "boom"}
658
- // (non-1000 codes raise `SocketCloseError`; the real code +
659
- // `closeReason` round-trip through.)
660
- //
661
- // The pre-deletion adapter hardcoded `{1000, "disconnect"}` regardless
662
- // of the actual close. A V7 mutation that re-introduces the hardcode
663
- // flips the second test's `code === 1011` assertion pass → fail,
664
- // satisfying AC 5.4-4 (distinguishable payloads). The OQ-5
665
- // default-fallback paths (HandshakeFailure / TransportFailure /
666
- // Unknown → DEFAULT_ABNORMAL_CLOSE) are covered by the direct unit
667
- // tests in `runtime/close-info.test.ts`, where synthetic `Exit`
668
- // values exercise each branch without a real transport.
669
- it("synthesizes the graceful default when the transport treats 1000 as Exit.Success", async () => {
670
- await withTestServer(Effect.gen(function* () {
671
- const closes = [];
672
- const server = yield* startHandshakingServer(() => Effect.void);
673
- const client = makeClient(server.url, {
674
- onDisconnect: (close) => {
675
- closes.push(close);
676
- },
677
- });
678
- yield* Effect.promise(() => connectP(client));
679
- yield* server.connections[0].close(1000, "bye");
680
- yield* Effect.promise(() => waitFor(() => closes.length > 0, { maxMs: 2000 }));
681
- yield* closeClient(client);
682
- expect(closes.length).toBeGreaterThanOrEqual(1);
683
- const first = closes[0];
684
- expect(first.code).toBe(1000);
685
- expect(first.reason).toBe("normal"); // OQ-5 default, not "bye"
686
- }));
310
+ // Two distinguishable closes through the live reader-fiber path:
311
+ // 1. Server-initiated 1000/"bye" → CloseInfo{1000, NORMAL_CLOSE_REASON}
312
+ // (`@effect/platform/Socket` treats 1000 as `Exit.Success` per
313
+ // its default `closeCodeIsError`; `extractCloseInfo` synthesizes
314
+ // the OQ-5 graceful default — the close-frame reason is dropped
315
+ // because there's no `SocketCloseError` to round-trip.)
316
+ // 2. Server-initiated 1011/SERVER_ERROR_REASON → CloseInfo{1011, SERVER_ERROR_REASON}
317
+ // (non-1000 codes raise `SocketCloseError`; the real code +
318
+ // `closeReason` round-trip through.)
319
+ //
320
+ // The pre-deletion adapter hardcoded `{1000, "disconnect"}` regardless
321
+ // of the actual close. A V7 mutation that re-introduces the hardcode
322
+ // flips the second test's `code === 1011` assertion pass → fail,
323
+ // satisfying AC 5.4-4 (distinguishable payloads). The OQ-5
324
+ // default-fallback paths (HandshakeFailure / TransportFailure /
325
+ // Unknown → DEFAULT_ABNORMAL_CLOSE) are covered by the direct unit
326
+ // tests in `runtime/close-info.test.ts`, where synthetic `Exit`
327
+ // values exercise each branch without a real transport.
328
+ effectTest("synthesizes the graceful default when the transport treats 1000 as Exit.Success", () => withTestServer(Effect.gen(function* () {
329
+ const closes = [];
330
+ const server = yield* startHandshakingServer(() => Effect.void);
331
+ const client = makeClient(server.url, {
332
+ onDisconnect: (close) => {
333
+ closes.push(close);
334
+ },
687
335
  });
688
- it("round-trips the server's close code + reason for a 1011 (server error) close", async () => {
689
- await withTestServer(Effect.gen(function* () {
690
- const closes = [];
691
- const server = yield* startHandshakingServer(() => Effect.void);
692
- const client = makeClient(server.url, {
693
- onDisconnect: (close) => {
694
- closes.push(close);
695
- },
696
- });
697
- yield* Effect.promise(() => connectP(client));
698
- yield* server.connections[0].close(1011, "boom");
699
- yield* Effect.promise(() => waitFor(() => closes.length > 0, { maxMs: 2000 }));
700
- yield* closeClient(client);
701
- expect(closes.length).toBeGreaterThanOrEqual(1);
702
- const first = closes[0];
703
- // Distinguishable from the 1000 test above (AC 5.4-4):
704
- // a hardcoded `{1000, "disconnect"}` would fail this assertion.
705
- expect(first.code).toBe(1011);
706
- expect(first.reason).toBe("boom");
707
- }));
336
+ yield* connectClient(client);
337
+ yield* server.connections[0].close(NORMAL_CLOSE_CODE, "bye");
338
+ yield* waitFor(() => closes.length > 0, {
339
+ maxMs: CLOSE_INFO_WAIT_MS,
708
340
  });
709
- });
341
+ yield* closeClient(client);
342
+ expect(closes.length).toBeGreaterThanOrEqual(1);
343
+ const first = closes[0];
344
+ expect(first.code).toBe(NORMAL_CLOSE_CODE);
345
+ expect(first.reason).toBe(NORMAL_CLOSE_REASON); // OQ-5 default, not "bye"
346
+ })));
347
+ effectTest("round-trips the server's close code + reason for a 1011 (server error) close", () => withTestServer(Effect.gen(function* () {
348
+ const closes = [];
349
+ const server = yield* startHandshakingServer(() => Effect.void);
350
+ const client = makeClient(server.url, {
351
+ onDisconnect: (close) => {
352
+ closes.push(close);
353
+ },
354
+ });
355
+ yield* connectClient(client);
356
+ yield* server.connections[0].close(SERVER_ERROR_CLOSE_CODE, SERVER_ERROR_REASON);
357
+ yield* waitFor(() => closes.length > 0, {
358
+ maxMs: CLOSE_INFO_WAIT_MS,
359
+ });
360
+ yield* closeClient(client);
361
+ expect(closes.length).toBeGreaterThanOrEqual(1);
362
+ const first = closes[0];
363
+ // Distinguishable from the 1000 test above (AC 5.4-4):
364
+ // a hardcoded `{1000, "disconnect"}` would fail this assertion.
365
+ expect(first.code).toBe(SERVER_ERROR_CLOSE_CODE);
366
+ expect(first.reason).toBe(SERVER_ERROR_REASON);
367
+ })));
710
368
  // ─────────────────────────────────────────────────────────────────────
711
369
  // Phase 1.0 (B.1) gating tests — client-side server-initiated RPC
712
370
  // (handleServerRpc + dispatcher fiber + appCallback response write-back)
713
371
  // ─────────────────────────────────────────────────────────────────────
714
- describe("Phase 1.0 (B.1) — handleServerRpc round-trip", () => {
715
- it("dispatches an inbound appCallback request to the registered handler and writes the response back", async () => {
716
- await withTestServer(Effect.gen(function* () {
717
- // Server: auto-handshake; immediately AFTER replying to
718
- // network/connect, send an appCallback request to the client; capture every
719
- // subsequent inbound frame the client writes back. We're testing
720
- // the client's dispatcher fiber + handler registry + response
721
- // encoding.
722
- const server = yield* startTestServer((conn, raw) => Effect.gen(function* () {
723
- const parsed = JSON.parse(raw);
724
- if (!validateRequestFrame(parsed)) {
725
- return yield* Effect.die("expected JSON-RPC request frame");
726
- }
727
- const frame = parsed;
728
- if (frame.method === Connect.name) {
729
- yield* conn.send(JSON.stringify(Connect.encodeResponse(frame.id, helloOk())));
730
- // Fire the appCallback request straight after auth response. The
731
- // client's dispatcher fiber was forked into the connect
732
- // scope BEFORE network/connect was sent, so the inbound queue
733
- // is live the moment we land here.
734
- yield* conn.send(JSON.stringify(DispatchAuthorize.encodeRequest("srv-test-1", dispatchRequestParams(SESSION_A))));
735
- }
736
- }));
737
- const client = makeClient(server.url);
738
- // Register a handler BEFORE connect so the dispatcher fiber sees
739
- // it on the very first inbound task-callback request. The
740
- // handler captures the `taskId` it was invoked with so the
741
- // assertion below can verify the request reached the right
742
- // descriptor's handler.
743
- let observedTaskId = null;
744
- yield* client.handleServerRpc(DispatchAuthorize, (params) => Effect.sync(() => {
745
- observedTaskId = params.taskId;
746
- return { admission: { decision: "grant" } };
747
- }));
748
- yield* Effect.promise(() => connectP(client));
749
- // Wait for the response frame the dispatcher writes back. The
750
- // server records every inbound frame in `received` — the appCallback
751
- // response should appear after the client's network/connect.
752
- const responseRaw = yield* Effect.promise(() => waitFor(() => server.connections[0].received.some((r) => {
753
- try {
754
- const parsed = JSON.parse(r);
755
- return (validateResponseFrame(parsed) && parsed.id === "srv-test-1");
756
- }
757
- catch {
758
- return false;
759
- }
760
- }), { maxMs: 2000 })).pipe(Effect.flatMap(() => Effect.sync(() => server.connections[0].received.find((r) => {
761
- try {
762
- const parsed = JSON.parse(r);
763
- return (validateResponseFrame(parsed) && parsed.id === "srv-test-1");
764
- }
765
- catch {
766
- return false;
767
- }
768
- }))));
769
- const parsedResponse = JSON.parse(responseRaw);
770
- expect(validateResponseFrame(parsedResponse)).toBe(true);
771
- if (!validateResponseFrame(parsedResponse))
772
- return;
773
- expect(parsedResponse.id).toBe("srv-test-1");
774
- expect("result" in parsedResponse).toBe(true);
775
- if (!("result" in parsedResponse))
776
- return;
777
- const result = parsedResponse.result;
778
- expect(result.admission.decision).toBe("grant");
779
- expect(observedTaskId).toBe(SESSION_A);
780
- yield* closeClient(client);
781
- }));
782
- });
783
- it("encodes a typed RpcServerError from the handler as a `response` frame with `error`", async () => {
784
- await withTestServer(Effect.gen(function* () {
785
- const server = yield* startTestServer((conn, raw) => Effect.gen(function* () {
786
- const parsed = JSON.parse(raw);
787
- if (!validateRequestFrame(parsed)) {
788
- return yield* Effect.die("expected JSON-RPC request frame");
789
- }
790
- const frame = parsed;
791
- if (frame.method === Connect.name) {
792
- yield* conn.send(JSON.stringify(Connect.encodeResponse(frame.id, helloOk())));
793
- yield* conn.send(JSON.stringify(DispatchAuthorize.encodeRequest("srv-err-1", dispatchRequestParams(SESSION_B))));
794
- }
795
- }));
796
- const client = makeClient(server.url);
797
- yield* client.handleServerRpc(DispatchAuthorize, () => Effect.fail(new RpcServerError({
798
- code: -32099,
799
- message: "domain-rejected",
800
- data: { reason: "test" },
801
- })));
802
- yield* Effect.promise(() => connectP(client));
803
- yield* Effect.promise(() => waitFor(() => server.connections[0].received.some((r) => {
804
- try {
805
- const parsed = JSON.parse(r);
806
- return (validateResponseFrame(parsed) &&
807
- parsed.id === "srv-err-1" &&
808
- "error" in parsed);
809
- }
810
- catch {
811
- return false;
812
- }
813
- }), { maxMs: 2000 }));
814
- const found = server.connections[0].received.find((r) => {
815
- try {
816
- const parsed = JSON.parse(r);
817
- return validateResponseFrame(parsed) && parsed.id === "srv-err-1";
818
- }
819
- catch {
820
- return false;
821
- }
822
- });
823
- const parsed = JSON.parse(found);
824
- expect(validateResponseFrame(parsed)).toBe(true);
825
- if (!validateResponseFrame(parsed))
826
- return;
827
- expect("error" in parsed).toBe(true);
828
- if (!("error" in parsed))
829
- return;
830
- expect(parsed.error.code).toBe(-32099);
831
- expect(parsed.error.message).toBe("domain-rejected");
832
- expect(parsed.error.data.reason).toBe("test");
833
- yield* closeClient(client);
834
- }));
372
+ effectTest("dispatches an inbound appCallback request to the registered handler and writes the response back", () => withTestServer(Effect.gen(function* () {
373
+ // Server: auto-handshake; immediately AFTER replying to
374
+ // network/connect, send an appCallback request to the client; capture every
375
+ // subsequent inbound frame the client writes back. We're testing
376
+ // the client's dispatcher fiber + handler registry + response
377
+ // encoding.
378
+ const server = yield* startDispatchAuthorizeServer(SERVER_TEST_REQUEST_ID, dispatchRequestParams(SESSION_A));
379
+ const client = makeClient(server.url);
380
+ // Register a handler BEFORE connect so the dispatcher fiber sees
381
+ // it on the very first inbound task-callback request. The
382
+ // handler captures the `taskId` it was invoked with so the
383
+ // assertion below can verify the request reached the right
384
+ // descriptor's handler.
385
+ const observedTaskId = { current: null };
386
+ yield* client.handleServerRpc(DispatchAuthorize, grantDispatchAuthorizeHandler(observedTaskId));
387
+ yield* connectClient(client);
388
+ // Wait for the response frame the dispatcher writes back. The
389
+ // server records every inbound frame in `received` — the appCallback
390
+ // response should appear after the client's network/connect.
391
+ const responseRaw = yield* waitForResponseRaw(server, SERVER_TEST_REQUEST_ID);
392
+ const parsedResponse = JSON.parse(responseRaw);
393
+ expect(validateResponseFrame(parsedResponse)).toBe(true);
394
+ if (!validateResponseFrame(parsedResponse))
395
+ return;
396
+ expect(parsedResponse.id).toBe(SERVER_TEST_REQUEST_ID);
397
+ expect("result" in parsedResponse).toBe(true);
398
+ if (!("result" in parsedResponse))
399
+ return;
400
+ const result = parsedResponse.result;
401
+ expect(result.admission.decision).toBe(GRANT_DECISION);
402
+ expect(observedTaskId.current).toBe(SESSION_A);
403
+ yield* closeClient(client);
404
+ })));
405
+ effectTest("encodes a registered handler error as a `response` frame with `error`", () => withTestServer(Effect.gen(function* () {
406
+ const server = yield* startDispatchAuthorizeServer(SERVER_ERROR_REQUEST_ID, dispatchRequestParams(SESSION_B));
407
+ const client = makeClient(server.url);
408
+ yield* client.handleServerRpc(DispatchAuthorize, () => Effect.fail(new ForbiddenError({
409
+ message: DOMAIN_REJECTED_MESSAGE,
410
+ data: { reason: DOMAIN_REJECTED_REASON },
411
+ })));
412
+ yield* connectClient(client);
413
+ yield* waitForErrorResponse(server, SERVER_ERROR_REQUEST_ID);
414
+ const found = findResponseRaw(server, SERVER_ERROR_REQUEST_ID);
415
+ const parsed = JSON.parse(found);
416
+ expect(validateResponseFrame(parsed)).toBe(true);
417
+ if (!validateResponseFrame(parsed))
418
+ return;
419
+ expect("error" in parsed).toBe(true);
420
+ if (!("error" in parsed))
421
+ return;
422
+ expect(parsed.error.code).toBe(HANDLER_REJECTION_CODE);
423
+ expect(parsed.error.message).toBe(DOMAIN_REJECTED_MESSAGE);
424
+ expect(parsed.error.data.reason).toBe(DOMAIN_REJECTED_REASON);
425
+ yield* closeClient(client);
426
+ })));
427
+ effectTest("rejects a duplicate handleServerRpc registration with DuplicateServerRpcHandlerError", () => Effect.gen(function* () {
428
+ const client = new MoltZapWsClient({
429
+ serverUrl: "http://127.0.0.1:1",
430
+ agentKey: "test",
835
431
  });
836
- it("rejects a duplicate handleServerRpc registration with DuplicateServerRpcHandlerError", async () => {
837
- const client = new MoltZapWsClient({
838
- serverUrl: "http://127.0.0.1:1",
839
- agentKey: "test",
840
- });
841
- const first = await Effect.runPromiseExit(client.handleServerRpc(DispatchAuthorize, () => Effect.succeed({ admission: { decision: "grant" } })));
842
- expect(Exit.isSuccess(first)).toBe(true);
843
- const second = await Effect.runPromiseExit(client.handleServerRpc(DispatchAuthorize, () => Effect.succeed({ admission: { decision: "grant" } })));
844
- expect(Exit.isFailure(second)).toBe(true);
845
- if (Exit.isFailure(second)) {
846
- const err = Cause.failureOption(second.cause);
847
- expect(err._tag).toBe("Some");
848
- if (err._tag === "Some") {
849
- expect(err.value._tag).toBe("DuplicateServerRpcHandlerError");
850
- }
432
+ const first = yield* Effect.exit(client.handleServerRpc(DispatchAuthorize, () => Effect.succeed({
433
+ admission: { decision: GRANT_DECISION },
434
+ })));
435
+ expect(Exit.isSuccess(first)).toBe(true);
436
+ const second = yield* Effect.exit(client.handleServerRpc(DispatchAuthorize, () => Effect.succeed({
437
+ admission: { decision: GRANT_DECISION },
438
+ })));
439
+ expect(Exit.isFailure(second)).toBe(true);
440
+ if (Exit.isFailure(second)) {
441
+ const err = Cause.failureOption(second.cause);
442
+ expect(Option.isSome(err)).toBe(true);
443
+ if (Option.isSome(err)) {
444
+ expect(err.value._tag).toBe(DUPLICATE_HANDLER_ERROR_TAG);
851
445
  }
852
- await Effect.runPromise(client.close());
853
- });
854
- });
446
+ }
447
+ yield* client.close();
448
+ }));
855
449
  // ─────────────────────────────────────────────────────────────────────
856
450
  // Regression gate (review-295): runSync(client.close()) and
857
451
  // runSync(client.disconnect()) must not throw AsyncFiberException after
@@ -866,26 +460,20 @@ describe("Phase 1.0 (B.1) — handleServerRpc round-trip", () => {
866
460
  // packages/openclaw-channel/src/__tests__/reconnection.integration.test.ts:14
867
461
  // (`closeWs = (c) => Effect.runSync(c.close())`).
868
462
  // ─────────────────────────────────────────────────────────────────────
869
- describe("Phase 1.0 (B.1) — runSync teardown contract", () => {
870
- it("client.close() runs synchronously after a successful connect", async () => {
871
- await withTestServer(Effect.gen(function* () {
872
- const server = yield* startHandshakingServer(() => Effect.void);
873
- const client = makeClient(server.url);
874
- yield* Effect.promise(() => connectP(client));
875
- // Critical: this MUST be runSync, not runPromise. Throwing
876
- // AsyncFiberException here is the regression we're guarding.
877
- expect(() => Effect.runSync(client.close())).not.toThrow();
878
- }));
879
- });
880
- it("client.disconnect() runs synchronously after a successful connect", async () => {
881
- await withTestServer(Effect.gen(function* () {
882
- const server = yield* startHandshakingServer(() => Effect.void);
883
- const client = makeClient(server.url);
884
- yield* Effect.promise(() => connectP(client));
885
- expect(() => Effect.runSync(client.disconnect())).not.toThrow();
886
- // Drain reconnect-fiber + runtime so vitest doesn't flag dangling work.
887
- yield* closeClient(client);
888
- }));
889
- });
890
- });
463
+ effectTest("client.close() runs synchronously after a successful connect", () => withTestServer(Effect.gen(function* () {
464
+ const server = yield* startHandshakingServer(() => Effect.void);
465
+ const client = makeClient(server.url);
466
+ yield* connectClient(client);
467
+ // Critical: this MUST be runSync, not runPromise. Throwing
468
+ // AsyncFiberException here is the regression we're guarding.
469
+ expect(() => Effect.runSync(client.close())).not.toThrow();
470
+ })));
471
+ effectTest("client.disconnect() runs synchronously after a successful connect", () => withTestServer(Effect.gen(function* () {
472
+ const server = yield* startHandshakingServer(() => Effect.void);
473
+ const client = makeClient(server.url);
474
+ yield* connectClient(client);
475
+ expect(() => Effect.runSync(client.disconnect())).not.toThrow();
476
+ // Drain reconnect-fiber + runtime so vitest doesn't flag dangling work.
477
+ yield* closeClient(client);
478
+ })));
891
479
  //# sourceMappingURL=ws-client.test.js.map