@moltzap/client 2026.511.1 → 2026.519.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 (218) 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-base/format-cross-conv.d.ts +43 -0
  8. package/dist/channel-base/format-cross-conv.d.ts.map +1 -0
  9. package/dist/channel-base/format-cross-conv.js +69 -0
  10. package/dist/channel-base/format-cross-conv.js.map +1 -0
  11. package/dist/channel-base/format-group-block.d.ts +43 -0
  12. package/dist/channel-base/format-group-block.d.ts.map +1 -0
  13. package/dist/channel-base/format-group-block.js +59 -0
  14. package/dist/channel-base/format-group-block.js.map +1 -0
  15. package/dist/channel-base/index.d.ts +21 -0
  16. package/dist/channel-base/index.d.ts.map +1 -0
  17. package/dist/channel-base/index.js +24 -0
  18. package/dist/channel-base/index.js.map +1 -0
  19. package/dist/channel-base/lease-guard.d.ts +30 -0
  20. package/dist/channel-base/lease-guard.d.ts.map +1 -0
  21. package/dist/channel-base/lease-guard.js +40 -0
  22. package/dist/channel-base/lease-guard.js.map +1 -0
  23. package/dist/channel-base/lease-store.d.ts +30 -0
  24. package/dist/channel-base/lease-store.d.ts.map +1 -0
  25. package/dist/channel-base/lease-store.js +58 -0
  26. package/dist/channel-base/lease-store.js.map +1 -0
  27. package/dist/channel-base/lease.d.ts +94 -0
  28. package/dist/channel-base/lease.d.ts.map +1 -0
  29. package/dist/channel-base/lease.js +110 -0
  30. package/dist/channel-base/lease.js.map +1 -0
  31. package/dist/channel-core-context.test.d.ts +2 -0
  32. package/dist/channel-core-context.test.d.ts.map +1 -0
  33. package/dist/channel-core-context.test.js +334 -0
  34. package/dist/channel-core-context.test.js.map +1 -0
  35. package/dist/channel-core-dispatch.test.d.ts +2 -0
  36. package/dist/channel-core-dispatch.test.d.ts.map +1 -0
  37. package/dist/channel-core-dispatch.test.js +550 -0
  38. package/dist/channel-core-dispatch.test.js.map +1 -0
  39. package/dist/channel-core-enrichment.d.ts +8 -0
  40. package/dist/channel-core-enrichment.d.ts.map +1 -0
  41. package/dist/channel-core-enrichment.js +134 -0
  42. package/dist/channel-core-enrichment.js.map +1 -0
  43. package/dist/channel-core-errors.d.ts +20 -0
  44. package/dist/channel-core-errors.d.ts.map +1 -0
  45. package/dist/channel-core-errors.js +12 -0
  46. package/dist/channel-core-errors.js.map +1 -0
  47. package/dist/channel-core-test-support.d.ts +48 -0
  48. package/dist/channel-core-test-support.d.ts.map +1 -0
  49. package/dist/channel-core-test-support.js +111 -0
  50. package/dist/channel-core-test-support.js.map +1 -0
  51. package/dist/channel-core.d.ts +30 -7
  52. package/dist/channel-core.d.ts.map +1 -1
  53. package/dist/channel-core.js +214 -237
  54. package/dist/channel-core.js.map +1 -1
  55. package/dist/channel-core.test.js +216 -1082
  56. package/dist/channel-core.test.js.map +1 -1
  57. package/dist/cli/__tests__/cli-multi-agent.int.test.js +129 -23
  58. package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -1
  59. package/dist/cli/commands/conversations-v2.test.js +64 -54
  60. package/dist/cli/commands/conversations-v2.test.js.map +1 -1
  61. package/dist/cli/commands/conversations.d.ts +5 -5
  62. package/dist/cli/commands/conversations.d.ts.map +1 -1
  63. package/dist/cli/commands/conversations.js +43 -36
  64. package/dist/cli/commands/conversations.js.map +1 -1
  65. package/dist/cli/commands/messages.d.ts +4 -4
  66. package/dist/cli/commands/messages.d.ts.map +1 -1
  67. package/dist/cli/commands/messages.js +4 -4
  68. package/dist/cli/commands/messages.js.map +1 -1
  69. package/dist/cli/commands/messages.test.js +61 -49
  70. package/dist/cli/commands/messages.test.js.map +1 -1
  71. package/dist/cli/commands/ping.d.ts +0 -9
  72. package/dist/cli/commands/ping.d.ts.map +1 -1
  73. package/dist/cli/commands/ping.js +1 -1
  74. package/dist/cli/commands/ping.js.map +1 -1
  75. package/dist/cli/commands/presence.d.ts +1 -1
  76. package/dist/cli/commands/presence.js +1 -1
  77. package/dist/cli/commands/register.d.ts +2 -2
  78. package/dist/cli/commands/register.d.ts.map +1 -1
  79. package/dist/cli/commands/register.js +98 -77
  80. package/dist/cli/commands/register.js.map +1 -1
  81. package/dist/cli/commands/register.test.js +63 -39
  82. package/dist/cli/commands/register.test.js.map +1 -1
  83. package/dist/cli/commands/send.d.ts +6 -6
  84. package/dist/cli/commands/send.js +6 -6
  85. package/dist/cli/commands/send.test.js +32 -28
  86. package/dist/cli/commands/send.test.js.map +1 -1
  87. package/dist/cli/commands/status.d.ts.map +1 -1
  88. package/dist/cli/commands/status.js +3 -4
  89. package/dist/cli/commands/status.js.map +1 -1
  90. package/dist/cli/config.d.ts +0 -2
  91. package/dist/cli/config.d.ts.map +1 -1
  92. package/dist/cli/config.js +86 -76
  93. package/dist/cli/config.js.map +1 -1
  94. package/dist/cli/config.test.js +80 -42
  95. package/dist/cli/config.test.js.map +1 -1
  96. package/dist/cli/index.d.ts +1 -16
  97. package/dist/cli/index.d.ts.map +1 -1
  98. package/dist/cli/index.js +50 -113
  99. package/dist/cli/index.js.map +1 -1
  100. package/dist/cli/process-argv.d.ts +2 -0
  101. package/dist/cli/process-argv.d.ts.map +1 -0
  102. package/dist/cli/process-argv.js +2 -0
  103. package/dist/cli/process-argv.js.map +1 -0
  104. package/dist/cli/profile.d.ts +3 -10
  105. package/dist/cli/profile.d.ts.map +1 -1
  106. package/dist/cli/profile.js +105 -104
  107. package/dist/cli/profile.js.map +1 -1
  108. package/dist/cli/profile.test.js +338 -232
  109. package/dist/cli/profile.test.js.map +1 -1
  110. package/dist/cli/runtime.d.ts +5 -17
  111. package/dist/cli/runtime.d.ts.map +1 -1
  112. package/dist/cli/runtime.js +6 -59
  113. package/dist/cli/runtime.js.map +1 -1
  114. package/dist/cli/socket-client.d.ts +10 -15
  115. package/dist/cli/socket-client.d.ts.map +1 -1
  116. package/dist/cli/socket-client.js +74 -89
  117. package/dist/cli/socket-client.js.map +1 -1
  118. package/dist/cli/transport.d.ts +9 -5
  119. package/dist/cli/transport.d.ts.map +1 -1
  120. package/dist/cli/transport.js +45 -42
  121. package/dist/cli/transport.js.map +1 -1
  122. package/dist/cli/transport.test.js +227 -133
  123. package/dist/cli/transport.test.js.map +1 -1
  124. package/dist/index.d.ts +4 -1
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/index.js +3 -0
  127. package/dist/index.js.map +1 -1
  128. package/dist/local-daemon-rpc.d.ts +13 -0
  129. package/dist/local-daemon-rpc.d.ts.map +1 -0
  130. package/dist/local-daemon-rpc.js +17 -0
  131. package/dist/local-daemon-rpc.js.map +1 -0
  132. package/dist/local-paths.d.ts +7 -0
  133. package/dist/local-paths.d.ts.map +1 -0
  134. package/dist/local-paths.js +20 -0
  135. package/dist/local-paths.js.map +1 -0
  136. package/dist/runtime/close-info.d.ts +3 -12
  137. package/dist/runtime/close-info.d.ts.map +1 -1
  138. package/dist/runtime/close-info.js +28 -39
  139. package/dist/runtime/close-info.js.map +1 -1
  140. package/dist/runtime/close-info.test.js +77 -70
  141. package/dist/runtime/close-info.test.js.map +1 -1
  142. package/dist/runtime/errors.test.js +42 -28
  143. package/dist/runtime/errors.test.js.map +1 -1
  144. package/dist/runtime/frame.d.ts +4 -31
  145. package/dist/runtime/frame.d.ts.map +1 -1
  146. package/dist/runtime/frame.js +7 -116
  147. package/dist/runtime/frame.js.map +1 -1
  148. package/dist/runtime/frame.test.js +41 -54
  149. package/dist/runtime/frame.test.js.map +1 -1
  150. package/dist/runtime/index.d.ts +7 -0
  151. package/dist/runtime/index.d.ts.map +1 -1
  152. package/dist/runtime/index.js +7 -11
  153. package/dist/runtime/index.js.map +1 -1
  154. package/dist/runtime/local-history.d.ts +67 -0
  155. package/dist/runtime/local-history.d.ts.map +1 -0
  156. package/dist/runtime/local-history.js +77 -0
  157. package/dist/runtime/local-history.js.map +1 -0
  158. package/dist/runtime/local-service-commands.d.ts +19 -0
  159. package/dist/runtime/local-service-commands.d.ts.map +1 -1
  160. package/dist/runtime/local-service-commands.js +23 -0
  161. package/dist/runtime/local-service-commands.js.map +1 -1
  162. package/dist/runtime/local-socket-server.d.ts +20 -0
  163. package/dist/runtime/local-socket-server.d.ts.map +1 -0
  164. package/dist/runtime/local-socket-server.js +94 -0
  165. package/dist/runtime/local-socket-server.js.map +1 -0
  166. package/dist/runtime/refs.d.ts +1 -1
  167. package/dist/runtime/refs.js +1 -1
  168. package/dist/runtime/runtime.test.js +29 -21
  169. package/dist/runtime/runtime.test.js.map +1 -1
  170. package/dist/runtime/service-helpers.d.ts +32 -0
  171. package/dist/runtime/service-helpers.d.ts.map +1 -0
  172. package/dist/runtime/service-helpers.js +85 -0
  173. package/dist/runtime/service-helpers.js.map +1 -0
  174. package/dist/runtime/subscribers.d.ts +13 -17
  175. package/dist/runtime/subscribers.d.ts.map +1 -1
  176. package/dist/runtime/subscribers.js +37 -32
  177. package/dist/runtime/subscribers.js.map +1 -1
  178. package/dist/runtime/subscribers.test.js +139 -131
  179. package/dist/runtime/subscribers.test.js.map +1 -1
  180. package/dist/service-socket-path.test.d.ts +2 -0
  181. package/dist/service-socket-path.test.d.ts.map +1 -0
  182. package/dist/service-socket-path.test.js +87 -0
  183. package/dist/service-socket-path.test.js.map +1 -0
  184. package/dist/service.d.ts +23 -20
  185. package/dist/service.d.ts.map +1 -1
  186. package/dist/service.js +192 -340
  187. package/dist/service.js.map +1 -1
  188. package/dist/service.test.js +652 -653
  189. package/dist/service.test.js.map +1 -1
  190. package/dist/test/index.d.ts +30 -14
  191. package/dist/test/index.d.ts.map +1 -1
  192. package/dist/test/index.js +25 -21
  193. package/dist/test/index.js.map +1 -1
  194. package/dist/test-utils/channel-service-fixture.d.ts +1 -0
  195. package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
  196. package/dist/test-utils/channel-service-fixture.js +139 -98
  197. package/dist/test-utils/channel-service-fixture.js.map +1 -1
  198. package/dist/test-utils/conformance-adapter.d.ts.map +1 -1
  199. package/dist/test-utils/conformance-adapter.js +145 -124
  200. package/dist/test-utils/conformance-adapter.js.map +1 -1
  201. package/dist/test-utils/fake-service.d.ts.map +1 -1
  202. package/dist/test-utils/fake-service.js +3 -3
  203. package/dist/test-utils/fake-service.js.map +1 -1
  204. package/dist/test-utils/index.d.ts +25 -0
  205. package/dist/test-utils/index.d.ts.map +1 -1
  206. package/dist/test-utils/index.js +25 -8
  207. package/dist/test-utils/index.js.map +1 -1
  208. package/dist/ws-client-test-support.d.ts +180 -0
  209. package/dist/ws-client-test-support.d.ts.map +1 -0
  210. package/dist/ws-client-test-support.js +399 -0
  211. package/dist/ws-client-test-support.js.map +1 -0
  212. package/dist/ws-client.d.ts +59 -32
  213. package/dist/ws-client.d.ts.map +1 -1
  214. package/dist/ws-client.js +272 -321
  215. package/dist/ws-client.js.map +1 -1
  216. package/dist/ws-client.test.js +388 -800
  217. package/dist/ws-client.test.js.map +1 -1
  218. package/package.json +18 -10
@@ -1,1098 +1,232 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { beforeEach, expect, it, vi } from "vitest";
2
2
  import { Effect } from "effect";
3
- import { MoltZapChannelCore, } from "./index.js";
4
- import { createFakeChannelService, buildMessage, flushDispatchChain, testAgentId, testConversationId, testMessageId, } from "./test-utils/index.js";
5
- import { RpcServerError } from "@moltzap/protocol";
6
- /**
7
- * Install a mock `requestDispatch` on the fake service that mints a
8
- * monotonic lease id, emits the resolved verdict via the
9
- * `dispatchRelease` event synchronously after the ack, and returns
10
- * the ack to channel-core. The verdict comes from `decide(request)`,
11
- * which mirrors the legacy `authorizeDispatch(request)` shape so
12
- * existing test bodies port mechanically. When `decide` returns an
13
- * Effect, the failure channel propagates to channel-core's admission
14
- * fail-closed path.
15
- */
16
- function installAdmission(fake, decide) {
17
- let counter = 0;
18
- fake.service.requestDispatch = (params) => Effect.flatMap(decide({
19
- message: {
20
- id: params.messageId,
21
- conversationId: params.conversationId,
22
- senderId: params.senderAgentId,
23
- parts: params.parts ?? [],
24
- createdAt: params.receivedAt ?? "1970-01-01T00:00:00.000Z",
25
- },
26
- conversationId: params.conversationId,
27
- senderAgentId: params.senderAgentId,
28
- attempt: params.attempt ?? 0,
29
- receivedAt: params.receivedAt ?? "1970-01-01T00:00:00.000Z",
30
- clock: params.clock,
31
- pending: (params.pending ?? []),
32
- }), (decision) => Effect.sync(() => {
33
- const leaseId = decision._tag === "grant" && decision.leaseId
34
- ? decision.leaseId
35
- : `lease-mock-${(++counter).toString()}`;
36
- const dispatchId = `dispatch-mock-${counter.toString()}`;
37
- // Fire the verdict via the dispatchRelease event AFTER the
38
- // ack returns. Channel-core's `awaitDispatchRelease` registers
39
- // a parking Deferred after consuming the ring buffer, so the
40
- // ack-then-release ordering is exercised here. Some tests
41
- // explicitly drive release-then-ack via `fake.emit.dispatchRelease`
42
- // before invoking the inbound message; both paths must work.
43
- const verdict = decision._tag === "grant"
44
- ? {
45
- decision: "grant",
46
- ...(decision.leaseId !== undefined
47
- ? { leaseId: decision.leaseId }
48
- : {}),
49
- ...(decision.leaseTimeoutMs !== undefined
50
- ? { leaseTimeoutMs: decision.leaseTimeoutMs }
51
- : {}),
52
- ...(decision.dispatchMessageId !== undefined
53
- ? { dispatchMessageId: decision.dispatchMessageId }
54
- : {}),
55
- }
56
- : decision._tag === "deny"
57
- ? {
58
- decision: "deny",
59
- ...(decision.reason !== undefined
60
- ? { reason: decision.reason }
61
- : {}),
62
- }
63
- : {
64
- decision: "hold",
65
- ...(decision.reason !== undefined
66
- ? { reason: decision.reason }
67
- : {}),
68
- };
69
- // Schedule the release on the next microtask so the ack
70
- // returns first; channel-core then registers the Deferred,
71
- // and the release settles it.
72
- queueMicrotask(() => {
73
- fake.emit.dispatchRelease({
74
- dispatchId,
75
- leaseId,
76
- verdict,
77
- ...(decision._tag === "grant" &&
78
- decision.leaseTimeoutMs !== undefined
79
- ? { leaseTimeoutMs: decision.leaseTimeoutMs }
80
- : {}),
81
- });
82
- });
83
- return { leaseId, dispatchId };
84
- }));
3
+ import { ALICE_CACHED_NAME, ALICE_RESOLVED_NAME, CAPTION_TEXT, MULTILINE_TEXT, TestInboundHandlerError, agent, buildMessage, conversation, createChannelCoreFixture, customSetup, effectTest, flushDispatchChainEffect, forceResolveAgentNamePath, message, } from "./channel-core-test-support.js";
4
+ let fake;
5
+ let core;
6
+ let inbound;
7
+ beforeEach(() => {
8
+ ({ fake, core, inbound } = createChannelCoreFixture());
9
+ });
10
+ function connectDelegatesToServiceAndSetsConnected() {
11
+ return Effect.gen(function* () {
12
+ expect(core.isConnected()).toBe(false);
13
+ yield* core.connect();
14
+ expect(fake.state.connectCalls.count).toBe(1);
15
+ expect(core.isConnected()).toBe(true);
16
+ });
85
17
  }
86
- const agent = testAgentId;
87
- const conversation = testConversationId;
88
- const message = testMessageId;
89
- const participant = (agentLabel) => `agent:${agent(agentLabel)}`;
90
- function customSetup() {
91
- const fake = createFakeChannelService({ ownAgentId: "agent-self" });
92
- const received = [];
93
- const infoSpy = vi.fn();
94
- const errorSpy = vi.fn();
95
- const core = new MoltZapChannelCore({
96
- service: fake.service,
97
- logger: { info: infoSpy, warn: () => { }, error: errorSpy },
18
+ effectTest("connect() delegates to service and sets connected", connectDelegatesToServiceAndSetsConnected);
19
+ function disconnectClosesTheServiceAndClearsTheConnectedFlag() {
20
+ return Effect.gen(function* () {
21
+ yield* core.connect();
22
+ yield* core.disconnect();
23
+ expect(fake.state.closeCalls.count).toBe(1);
24
+ expect(core.isConnected()).toBe(false);
98
25
  });
99
- core.onInbound((m) => Effect.sync(() => {
100
- received.push(m);
101
- }));
102
- return { fake, core, received, infoSpy, errorSpy };
103
26
  }
104
- /** Stub out getAgentName on the fixture's service so resolveAgentName is the only path. */
105
- function forceResolveAgentNamePath(fake) {
106
- fake.service.getAgentName = () => undefined;
27
+ effectTest("disconnect() closes the service and clears the connected flag", disconnectClosesTheServiceAndClearsTheConnectedFlag);
28
+ function disconnectEventFromTheServiceClearsTheConnectedFlag() {
29
+ return Effect.gen(function* () {
30
+ yield* core.connect();
31
+ fake.emit.disconnect();
32
+ expect(core.isConnected()).toBe(false);
33
+ });
107
34
  }
108
- describe("MoltZapChannelCore", () => {
109
- let fake;
110
- let service;
111
- let core;
112
- let inbound;
113
- beforeEach(() => {
114
- fake = createFakeChannelService({ ownAgentId: "agent-self" });
115
- service = fake.service;
116
- core = new MoltZapChannelCore({ service });
117
- inbound = [];
118
- core.onInbound((msg) => Effect.sync(() => {
119
- inbound.push(msg);
120
- }));
35
+ effectTest("disconnect event from the service clears the connected flag", disconnectEventFromTheServiceClearsTheConnectedFlag);
36
+ it("reconnect event from the service sets the connected flag", () => {
37
+ fake.emit.reconnect();
38
+ expect(core.isConnected()).toBe(true);
39
+ });
40
+ function onDisconnectHandlersFireOnDisconnectEvent() {
41
+ return Effect.gen(function* () {
42
+ const spy = vi.fn();
43
+ core.onDisconnect(spy);
44
+ yield* core.connect();
45
+ fake.emit.disconnect();
46
+ expect(spy).toHaveBeenCalledOnce();
121
47
  });
122
- describe("lifecycle", () => {
123
- it("connect() delegates to service and sets connected", async () => {
124
- expect(core.isConnected()).toBe(false);
125
- await Effect.runPromise(core.connect());
126
- expect(fake.state.connectCalls.count).toBe(1);
127
- expect(core.isConnected()).toBe(true);
128
- });
129
- it("disconnect() closes the service and clears the connected flag", async () => {
130
- await Effect.runPromise(core.connect());
131
- await Effect.runPromise(core.disconnect());
132
- expect(fake.state.closeCalls.count).toBe(1);
133
- expect(core.isConnected()).toBe(false);
134
- });
135
- it("disconnect event from the service clears the connected flag", async () => {
136
- await Effect.runPromise(core.connect());
137
- fake.emit.disconnect();
138
- expect(core.isConnected()).toBe(false);
139
- });
140
- it("reconnect event from the service sets the connected flag", () => {
141
- fake.emit.reconnect();
142
- expect(core.isConnected()).toBe(true);
143
- });
144
- it("onDisconnect handlers fire on disconnect event", async () => {
145
- const spy = vi.fn();
146
- core.onDisconnect(spy);
147
- await Effect.runPromise(core.connect());
148
- fake.emit.disconnect();
149
- expect(spy).toHaveBeenCalledOnce();
150
- });
151
- it("onReconnect handlers fire on reconnect event", () => {
152
- const spy = vi.fn();
153
- core.onReconnect(spy);
154
- fake.emit.reconnect();
155
- expect(spy).toHaveBeenCalledOnce();
48
+ }
49
+ effectTest("onDisconnect handlers fire on disconnect event", onDisconnectHandlersFireOnDisconnectEvent);
50
+ it("onReconnect handlers fire on reconnect event", () => {
51
+ const spy = vi.fn();
52
+ core.onReconnect(spy);
53
+ fake.emit.reconnect();
54
+ expect(spy).toHaveBeenCalledOnce();
55
+ });
56
+ function mapsAMoltZapMessageToEnrichedInboundMessage() {
57
+ return Effect.gen(function* () {
58
+ fake.state.setConversation("conv-1", {
59
+ type: "dm",
60
+ name: "alice-dm",
61
+ participants: ["agent:agent-alice", "agent:agent-self"],
62
+ });
63
+ fake.state.setAgentName("agent-alice", "Alice");
64
+ fake.emit.message(buildMessage({
65
+ id: "msg-abc",
66
+ conversationId: "conv-1",
67
+ senderId: "agent-alice",
68
+ parts: [{ type: "text", text: "hi there" }],
69
+ createdAt: "2026-04-10T13:00:00.000Z",
70
+ }));
71
+ yield* flushDispatchChainEffect;
72
+ expect(inbound).toHaveLength(1);
73
+ const enriched = inbound[0];
74
+ expect(enriched).toMatchObject({
75
+ id: message("msg-abc"),
76
+ conversationId: conversation("conv-1"),
77
+ sender: { id: agent("agent-alice"), name: "Alice" },
78
+ text: "hi there",
79
+ isFromMe: false,
80
+ createdAt: "2026-04-10T13:00:00.000Z",
81
+ });
82
+ expect(enriched.conversationMeta).toMatchObject({
83
+ type: "dm",
84
+ name: "alice-dm",
156
85
  });
157
86
  });
158
- describe("inbound message enrichment", () => {
159
- it("maps a MoltZap Message to EnrichedInboundMessage", async () => {
160
- fake.state.setConversation("conv-1", {
161
- type: "dm",
162
- name: "alice-dm",
163
- participants: ["agent:agent-alice", "agent:agent-self"],
164
- });
165
- fake.state.setAgentName("agent-alice", "Alice");
166
- fake.emit.message(buildMessage({
167
- id: "msg-abc",
168
- conversationId: "conv-1",
169
- senderId: "agent-alice",
170
- parts: [{ type: "text", text: "hi there" }],
171
- createdAt: "2026-04-10T13:00:00.000Z",
172
- }));
173
- await flushDispatchChain();
174
- expect(inbound).toHaveLength(1);
175
- const enriched = inbound[0];
176
- expect(enriched).toMatchObject({
177
- id: message("msg-abc"),
178
- conversationId: conversation("conv-1"),
179
- sender: { id: agent("agent-alice"), name: "Alice" },
180
- text: "hi there",
181
- isFromMe: false,
182
- createdAt: "2026-04-10T13:00:00.000Z",
183
- });
184
- expect(enriched.conversationMeta).toMatchObject({
185
- type: "dm",
186
- name: "alice-dm",
187
- });
188
- });
189
- it("resolves sender name from getAgentName cache when present", async () => {
190
- fake.state.setAgentName("agent-alice", "Alice (cached)");
191
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
192
- fake.emit.message(buildMessage());
193
- await flushDispatchChain();
194
- expect(inbound[0].sender.name).toBe("Alice (cached)");
195
- expect(fake.state.resolveAgentNameCallCount("agent-alice")).toBe(0);
196
- });
197
- it("falls back to resolveAgentName when getAgentName returns undefined", async () => {
198
- const { fake, received } = customSetup();
199
- forceResolveAgentNamePath(fake);
200
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
201
- fake.state.setAgentName("agent-alice", "Alice (via resolve)");
202
- fake.emit.message(buildMessage());
203
- await flushDispatchChain();
204
- expect(received[0].sender.name).toBe("Alice (via resolve)");
205
- expect(fake.state.resolveAgentNameCallCount("agent-alice")).toBe(1);
206
- });
207
- it("falls back to sender.id when both name lookups fail", async () => {
208
- const { fake, received } = customSetup();
209
- forceResolveAgentNamePath(fake);
210
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
211
- fake.emit.message(buildMessage({ senderId: "agent-unknown" }));
212
- await flushDispatchChain();
213
- expect(received[0].sender.name).toBe(agent("agent-unknown"));
214
- });
215
- it("swallows resolveAgentName errors and falls back to sender.id", async () => {
216
- const { fake, received } = customSetup();
217
- forceResolveAgentNamePath(fake);
218
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
219
- fake.state.setResolveAgentNameFailure("agent-broken", new Error("network down"));
220
- fake.emit.message(buildMessage({ senderId: "agent-broken" }));
221
- await flushDispatchChain();
222
- expect(received[0].sender.name).toBe(agent("agent-broken"));
223
- });
224
- it("concatenates multi-text-part messages with newlines", async () => {
225
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
226
- fake.state.setAgentName("agent-alice", "Alice");
227
- fake.emit.message(buildMessage({
228
- parts: [
229
- { type: "text", text: "line one" },
230
- { type: "text", text: "line two" },
231
- ],
232
- }));
233
- await flushDispatchChain();
234
- expect(inbound[0].text).toBe("line one\nline two");
235
- });
236
- it("ignores non-text parts when building text", async () => {
237
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
238
- fake.state.setAgentName("agent-alice", "Alice");
239
- fake.emit.message(buildMessage({
240
- parts: [
241
- { type: "text", text: "caption" },
242
- { type: "image", url: "https://example.com/pic.png" },
243
- ],
244
- }));
245
- await flushDispatchChain();
246
- expect(inbound[0].text).toBe("caption");
247
- });
248
- it("sets isFromMe=true when sender matches ownAgentId", async () => {
249
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
250
- fake.emit.message(buildMessage({ senderId: "agent-self" }));
251
- await flushDispatchChain();
252
- expect(inbound[0].isFromMe).toBe(true);
253
- });
254
- it("forwards replyToId from the message frame", async () => {
255
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
256
- fake.state.setAgentName("agent-alice", "Alice");
257
- fake.emit.message(buildMessage({ replyToId: "msg-parent-123" }));
258
- await flushDispatchChain();
259
- expect(inbound[0].replyToId).toBe(message("msg-parent-123"));
260
- });
261
- it("logs failures from the inbound handler's Effect error channel and keeps the consumer alive", async () => {
262
- const { fake, errorSpy, core } = customSetup();
263
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
264
- fake.state.setAgentName("agent-alice", "Alice");
265
- let handlerShouldFail = true;
266
- const received = [];
267
- // Replace the setup's default capture handler with one that can fail.
268
- core.onInbound((m) => Effect.gen(function* () {
269
- if (handlerShouldFail) {
270
- yield* Effect.fail(new Error("handler boom"));
271
- }
272
- received.push(m);
273
- }));
274
- fake.emit.message(buildMessage({ id: "msg-1" }));
275
- await flushDispatchChain();
276
- expect(received).toHaveLength(0);
277
- expect(errorSpy).toHaveBeenCalledOnce();
278
- // Recovery: subsequent message lands cleanly.
279
- handlerShouldFail = false;
280
- fake.emit.message(buildMessage({ id: "msg-2" }));
281
- await flushDispatchChain();
282
- expect(received).toHaveLength(1);
283
- expect(received[0].id).toBe(message("msg-2"));
284
- });
285
- it("logs synchronous defects thrown from inside the handler's Effect", async () => {
286
- const { fake, errorSpy, core } = customSetup();
287
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
288
- fake.state.setAgentName("agent-alice", "Alice");
289
- core.onInbound((_m) => Effect.sync(() => {
290
- throw new Error("sync defect");
291
- }));
292
- fake.emit.message(buildMessage({ id: "msg-1" }));
293
- await flushDispatchChain();
294
- expect(errorSpy).toHaveBeenCalledOnce();
295
- // Consumer fiber survives a defect and continues to dispatch later messages.
296
- const next = [];
297
- core.onInbound((m) => Effect.sync(() => {
298
- next.push(m);
299
- }));
300
- fake.emit.message(buildMessage({ id: "msg-2" }));
301
- await flushDispatchChain();
302
- expect(next.map((r) => r.id)).toEqual([message("msg-2")]);
303
- });
87
+ }
88
+ effectTest("maps a MoltZap Message to EnrichedInboundMessage", mapsAMoltZapMessageToEnrichedInboundMessage);
89
+ function resolvesSenderNameFromGetAgentNameCacheWhenPresent() {
90
+ return Effect.gen(function* () {
91
+ fake.state.setAgentName("agent-alice", ALICE_CACHED_NAME);
92
+ fake.state.setConversation("conv-1", { type: "dm", participants: [] });
93
+ fake.emit.message(buildMessage());
94
+ yield* flushDispatchChainEffect;
95
+ expect(inbound[0].sender.name).toBe(ALICE_CACHED_NAME);
96
+ expect(fake.state.resolveAgentNameCallCount("agent-alice")).toBe(0);
304
97
  });
305
- describe("dispatch chain ordering", () => {
306
- it("asks optional dispatch admission before delivering inbound work", async () => {
307
- const { fake, received } = customSetup();
308
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
309
- fake.state.setAgentName("agent-alice", "Alice");
310
- const requests = [];
311
- installAdmission(fake, (request) => Effect.sync(() => {
312
- requests.push({
313
- messageId: request.message.id,
314
- attempt: request.attempt,
315
- });
316
- return { _tag: "grant", leaseId: "lease-1" };
317
- }));
318
- fake.emit.message(buildMessage({ id: "msg-1" }));
319
- await flushDispatchChain();
320
- expect(requests).toEqual([{ messageId: message("msg-1"), attempt: 0 }]);
321
- expect(received.map((m) => m.id)).toEqual([message("msg-1")]);
322
- });
323
- it("reports a per-conversation observed logical clock to admission", async () => {
324
- const { fake } = customSetup();
325
- const clocks = [];
326
- installAdmission(fake, (request) => Effect.sync(() => {
327
- clocks.push(request.clock);
328
- expect(request.pending[0]?.clock).toEqual(request.clock);
329
- return { _tag: "grant" };
330
- }));
331
- fake.emit.message(buildMessage({
332
- id: "msg-1",
333
- senderId: "agent-alice",
334
- conversationId: "conv-1",
335
- }));
336
- await flushDispatchChain();
337
- fake.emit.message(buildMessage({
338
- id: "msg-2",
339
- senderId: "agent-bob",
340
- conversationId: "conv-1",
341
- }));
342
- await flushDispatchChain();
343
- expect(clocks).toEqual([
344
- {
345
- domainId: conversation("conv-1"),
346
- epoch: 1,
347
- vector: { [agent("agent-alice")]: 1 },
348
- },
349
- {
350
- domainId: conversation("conv-1"),
351
- epoch: 2,
352
- vector: { [agent("agent-alice")]: 1, [agent("agent-bob")]: 1 },
353
- },
354
- ]);
355
- });
356
- it("attaches the active dispatch lease to replies made during handler execution", async () => {
357
- const fake = createFakeChannelService({ ownAgentId: "agent-self" });
358
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
359
- fake.state.setAgentName("agent-alice", "Alice");
360
- installAdmission(fake, () => Effect.succeed({ _tag: "grant", leaseId: "lease-active" }));
361
- const core = new MoltZapChannelCore({ service: fake.service });
362
- core.onInbound((msg) => core.sendReply(msg.conversationId, "reply"));
363
- fake.emit.message(buildMessage({ id: "msg-with-lease" }));
364
- await flushDispatchChain();
365
- expect(fake.state.sent).toEqual([
366
- {
367
- convId: conversation("conv-1"),
368
- text: "reply",
369
- dispatchLeaseId: "lease-active",
370
- },
371
- ]);
372
- });
373
- it("passes the active dispatch lease to the inbound handler for async runtimes", async () => {
374
- const fake = createFakeChannelService({ ownAgentId: "agent-self" });
375
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
376
- fake.state.setAgentName("agent-alice", "Alice");
377
- installAdmission(fake, () => Effect.succeed({ _tag: "grant", leaseId: "lease-visible" }));
378
- const core = new MoltZapChannelCore({ service: fake.service });
379
- const leases = [];
380
- core.onInbound((msg) => Effect.sync(() => {
381
- leases.push(msg.dispatchLeaseId);
382
- }));
383
- fake.emit.message(buildMessage({ id: "msg-with-visible-lease" }));
384
- await flushDispatchChain();
385
- expect(leases).toEqual(["lease-visible"]);
386
- });
387
- it("preserves service binding for dispatch admission methods", async () => {
388
- const { fake, received } = customSetup();
389
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
390
- fake.state.setAgentName("agent-alice", "Alice");
391
- // Counter lives on the service object so the test asserts the
392
- // channel-core admission call site invokes `requestDispatch` with
393
- // the service as `this` (a `.bind(undefined)` would crash).
394
- const boundService = fake.service;
395
- boundService.admissionCalls = 0;
396
- // Replace the install-helper-installed `requestDispatch` with a
397
- // method-form binding that increments via `this.admissionCalls`.
398
- // Channel-core MUST call `service.requestDispatch(...)` (not
399
- // `requestDispatch.call(undefined, ...)`); a missing receiver
400
- // makes `this.admissionCalls += 1` throw on null `this`.
401
- installAdmission(fake, () => Effect.succeed({ _tag: "grant" }));
402
- const installed = fake.service.requestDispatch;
403
- fake.service.requestDispatch = function (request) {
404
- this.admissionCalls +=
405
- 1;
406
- return installed(request);
407
- };
408
- fake.emit.message(buildMessage({ id: "msg-bound-admission" }));
409
- await flushDispatchChain();
410
- expect(boundService.admissionCalls).toBe(1);
411
- expect(received.map((m) => m.id)).toEqual([
412
- message("msg-bound-admission"),
413
- ]);
414
- });
415
- it("drops denied inbound dispatch work without calling the handler", async () => {
416
- const { fake, received, infoSpy } = customSetup();
417
- installAdmission(fake, () => Effect.succeed({
418
- _tag: "deny",
419
- reason: "not this slot",
420
- }));
421
- fake.emit.message(buildMessage({ id: "msg-denied" }));
422
- await flushDispatchChain();
423
- expect(received).toHaveLength(0);
424
- expect(infoSpy).toHaveBeenCalledWith(expect.objectContaining({
425
- messageId: message("msg-denied"),
426
- attempt: 0,
427
- reason: "not this slot",
428
- }), "MoltZapChannelCore: inbound dispatch denied");
429
- });
430
- it("holds head-of-line work until a new inbound message refreshes the snapshot", async () => {
431
- const { fake, received, infoSpy } = customSetup();
432
- fake.state.setConversation("conv-1", { type: "group", participants: [] });
433
- fake.state.setAgentName("agent-alice", "Alice");
434
- fake.state.setAgentName("agent-bob", "Bob");
435
- const pendingSnapshots = [];
436
- let calls = 0;
437
- installAdmission(fake, (request) => Effect.sync(() => {
438
- calls += 1;
439
- pendingSnapshots.push(request.pending.map((m) => m.messageId));
440
- return calls === 1
441
- ? { _tag: "hold", reason: "not_yet" }
442
- : { _tag: "grant", leaseId: "lease-after-hold" };
443
- }));
444
- fake.emit.message(buildMessage({
445
- id: "msg-1",
446
- senderId: "agent-alice",
447
- conversationId: "conv-1",
448
- parts: [{ type: "text", text: "first" }],
449
- }));
450
- await flushDispatchChain();
451
- expect(calls).toBe(1);
452
- expect(received).toHaveLength(0);
453
- fake.emit.message(buildMessage({
454
- id: "msg-2",
455
- senderId: "agent-bob",
456
- conversationId: "conv-1",
457
- parts: [{ type: "text", text: "second" }],
458
- }));
459
- await flushDispatchChain();
460
- expect(pendingSnapshots).toEqual([
461
- [message("msg-1")],
462
- [message("msg-1"), message("msg-2")],
463
- ]);
464
- expect(received).toHaveLength(1);
465
- expect(received[0].id).toBe(message("msg-1"));
466
- expect(received[0].text).toContain("first");
467
- expect(received[0].text).toContain("second");
468
- expect(received[0].coalescedMessages?.map((m) => m.id)).toEqual([
469
- message("msg-1"),
470
- message("msg-2"),
471
- ]);
472
- expect(infoSpy).toHaveBeenCalledWith(expect.objectContaining({
473
- messageId: message("msg-1"),
474
- attempt: 0,
475
- reason: "not_yet",
476
- }), "MoltZapChannelCore: inbound dispatch held");
477
- });
478
- it("does not let held work in one conversation block another conversation", async () => {
479
- const { fake, received } = customSetup();
480
- fake.state.setConversation("town-square", {
481
- type: "group",
482
- participants: [],
483
- });
484
- fake.state.setConversation("werewolf-den", {
485
- type: "group",
486
- participants: [],
487
- });
488
- fake.state.setAgentName("agent-gm", "GM");
489
- const requests = [];
490
- installAdmission(fake, (request) => Effect.sync(() => {
491
- requests.push({
492
- messageId: request.message.id,
493
- conversationId: request.conversationId,
494
- pending: request.pending.map((m) => m.messageId),
495
- });
496
- if (request.conversationId === conversation("town-square")) {
497
- return { _tag: "hold", reason: "town_square_night" };
498
- }
499
- return { _tag: "grant", leaseId: "lease-den" };
500
- }));
501
- fake.emit.message(buildMessage({
502
- id: "town-night-narration",
503
- senderId: "agent-gm",
504
- conversationId: "town-square",
505
- parts: [{ type: "text", text: "Night falls." }],
506
- }));
507
- await flushDispatchChain();
508
- fake.emit.message(buildMessage({
509
- id: "den-kill-prompt",
510
- senderId: "agent-gm",
511
- conversationId: "werewolf-den",
512
- parts: [{ type: "text", text: "Werewolves, choose a target." }],
513
- }));
514
- await flushDispatchChain();
515
- expect(requests).toEqual([
516
- {
517
- messageId: message("town-night-narration"),
518
- conversationId: conversation("town-square"),
519
- pending: [message("town-night-narration")],
520
- },
521
- {
522
- messageId: message("den-kill-prompt"),
523
- conversationId: conversation("werewolf-den"),
524
- pending: [
525
- message("den-kill-prompt"),
526
- message("town-night-narration"),
527
- ],
528
- },
529
- ]);
530
- expect(received.map((m) => m.id)).toEqual([message("den-kill-prompt")]);
531
- expect(received[0].conversationId).toBe(conversation("werewolf-den"));
532
- expect(received[0].dispatchLeaseId).toBe("lease-den");
533
- });
534
- it("purges held and queued dispatch work when a conversation is archived", async () => {
535
- const { fake, received, infoSpy } = customSetup();
536
- fake.state.setConversation("conv-1", { type: "group", participants: [] });
537
- fake.state.setAgentName("agent-alice", "Alice");
538
- let calls = 0;
539
- installAdmission(fake, () => Effect.sync(() => {
540
- calls += 1;
541
- return { _tag: "hold", reason: "waiting" };
542
- }));
543
- fake.emit.message(buildMessage({ id: "msg-held" }));
544
- await flushDispatchChain();
545
- fake.emit.conversationArchived({ conversationId: "conv-1" });
546
- fake.emit.message(buildMessage({ id: "msg-after-archive" }));
547
- await flushDispatchChain();
548
- expect(calls).toBe(1);
549
- expect(received).toHaveLength(0);
550
- expect(infoSpy).toHaveBeenCalledWith(expect.objectContaining({
551
- conversationId: conversation("conv-1"),
552
- }), "MoltZapChannelCore: closed conversation dispatch work purged");
553
- expect(infoSpy).toHaveBeenCalledWith(expect.objectContaining({
554
- messageId: message("msg-after-archive"),
555
- conversationId: conversation("conv-1"),
556
- }), "MoltZapChannelCore: dropping inbound message for closed conversation");
557
- });
558
- it("keeps blocked authorization head-of-line and coalesces same-conversation backlog on grant", async () => {
559
- const { fake, received } = customSetup();
560
- fake.state.setConversation("conv-1", { type: "group", participants: [] });
561
- fake.state.setAgentName("agent-alice", "Alice");
562
- fake.state.setAgentName("agent-bob", "Bob");
563
- const pendingSnapshots = [];
564
- let grant;
565
- installAdmission(fake, (request) => Effect.gen(function* () {
566
- pendingSnapshots.push(request.pending);
567
- yield* Effect.async((resume) => {
568
- grant = () => resume(Effect.void);
569
- });
570
- return { _tag: "grant", leaseId: "lease-next" };
571
- }));
572
- fake.emit.message(buildMessage({
573
- id: "msg-1",
574
- senderId: "agent-alice",
575
- conversationId: "conv-1",
576
- parts: [{ type: "text", text: "first" }],
577
- }));
578
- await flushDispatchChain();
579
- expect(received).toHaveLength(0);
580
- fake.emit.message(buildMessage({
581
- id: "msg-2",
582
- senderId: "agent-bob",
583
- conversationId: "conv-1",
584
- parts: [{ type: "text", text: "second" }],
585
- }));
586
- grant();
587
- await flushDispatchChain();
588
- expect(pendingSnapshots.map((snapshot) => snapshot.map((m) => m.messageId))).toEqual([[message("msg-1")]]);
589
- expect(received).toHaveLength(1);
590
- expect(received[0].id).toBe(message("msg-1"));
591
- expect(received[0].text).toContain("first");
592
- expect(received[0].text).toContain("second");
593
- expect(received[0].coalescedMessages?.map((m) => m.id)).toEqual([
594
- message("msg-1"),
595
- message("msg-2"),
596
- ]);
597
- });
598
- it("dispatches an admitted pending marker and drops older same-conversation work", async () => {
599
- const { fake, received } = customSetup();
600
- fake.state.setConversation("conv-1", { type: "group", participants: [] });
601
- fake.state.setAgentName("agent-alice", "Alice");
602
- fake.state.setAgentName("agent-gm", "GM");
603
- let grant;
604
- installAdmission(fake, () => Effect.gen(function* () {
605
- yield* Effect.async((resume) => {
606
- grant = () => resume(Effect.void);
607
- });
608
- return {
609
- _tag: "grant",
610
- leaseId: "lease-marker",
611
- dispatchMessageId: message("msg-marker"),
612
- };
613
- }));
614
- fake.emit.message(buildMessage({
615
- id: "msg-old",
616
- senderId: "agent-alice",
617
- conversationId: "conv-1",
618
- parts: [{ type: "text", text: "old discussion" }],
619
- }));
620
- await flushDispatchChain();
621
- fake.emit.message(buildMessage({
622
- id: "msg-marker",
623
- senderId: "agent-gm",
624
- conversationId: "conv-1",
625
- parts: [{ type: "text", text: "Time to vote" }],
626
- }));
627
- fake.emit.message(buildMessage({
628
- id: "msg-after",
629
- senderId: "agent-alice",
630
- conversationId: "conv-1",
631
- parts: [{ type: "text", text: "after marker" }],
632
- }));
633
- grant();
634
- await flushDispatchChain();
635
- expect(received).toHaveLength(1);
636
- expect(received[0].id).toBe(message("msg-marker"));
637
- expect(received[0].text).toContain("Time to vote");
638
- expect(received[0].text).toContain("after marker");
639
- expect(received[0].text).not.toContain("old discussion");
640
- expect(received[0].coalescedMessages?.map((m) => m.id)).toEqual([
641
- message("msg-marker"),
642
- message("msg-after"),
643
- ]);
644
- expect(received[0].dispatchLeaseId).toBe("lease-marker");
645
- });
646
- it("fails closed when dispatch admission errors", async () => {
647
- const fake = createFakeChannelService({ ownAgentId: "agent-self" });
648
- const received = [];
649
- const errorSpy = vi.fn();
650
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
651
- fake.state.setAgentName("agent-alice", "Alice");
652
- const warnSpy = vi.fn();
653
- const core = new MoltZapChannelCore({
654
- service: fake.service,
655
- logger: { info: () => { }, warn: warnSpy, error: errorSpy },
656
- });
657
- core.onInbound((m) => Effect.sync(() => {
658
- received.push(m);
659
- }));
660
- installAdmission(fake, () => Effect.fail(new RpcServerError({
661
- code: -32603,
662
- message: "admission service unavailable",
663
- })));
664
- fake.emit.message(buildMessage({ id: "msg-fail-closed" }));
665
- await flushDispatchChain();
666
- expect(received).toHaveLength(0);
667
- expect(warnSpy).toHaveBeenCalledWith(expect.objectContaining({
668
- messageId: message("msg-fail-closed"),
669
- attempt: 0,
670
- }), "MoltZapChannelCore: dispatch admission failed closed");
671
- });
672
- it("fails closed when dispatch admission hangs", async () => {
673
- const fake = createFakeChannelService({ ownAgentId: "agent-self" });
674
- const received = [];
675
- const errorSpy = vi.fn();
676
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
677
- fake.state.setAgentName("agent-alice", "Alice");
678
- const warnSpy = vi.fn();
679
- const core = new MoltZapChannelCore({
680
- service: fake.service,
681
- logger: { info: () => { }, warn: warnSpy, error: errorSpy },
682
- dispatchAdmissionTimeoutMs: 1,
683
- });
684
- core.onInbound((m) => Effect.sync(() => {
685
- received.push(m);
686
- }));
687
- installAdmission(fake, () => Effect.never);
688
- fake.emit.message(buildMessage({ id: "msg-timeout-closed" }));
689
- await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledWith(expect.objectContaining({
690
- messageId: message("msg-timeout-closed"),
691
- attempt: 0,
692
- }), "MoltZapChannelCore: dispatch admission failed closed"), { timeout: 1_000 });
693
- await flushDispatchChain();
694
- expect(received).toHaveLength(0);
695
- });
696
- it("continues draining inbound work after a dispatch lease expires", async () => {
697
- const fake = createFakeChannelService({ ownAgentId: "agent-self" });
698
- const received = [];
699
- const warnSpy = vi.fn();
700
- const errorSpy = vi.fn();
701
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
702
- fake.state.setAgentName("agent-alice", "Alice");
703
- installAdmission(fake, (request) => Effect.succeed({
704
- _tag: "grant",
705
- leaseId: `lease-${request.message.id}`,
706
- leaseTimeoutMs: request.message.id === message("msg-stuck") ? 1 : 50,
707
- }));
708
- const core = new MoltZapChannelCore({
709
- service: fake.service,
710
- logger: { info: () => { }, warn: warnSpy, error: errorSpy },
711
- });
712
- core.onInbound((m) => {
713
- if (m.id === message("msg-stuck"))
714
- return Effect.never;
715
- return Effect.sync(() => {
716
- received.push(m.id);
717
- });
718
- });
719
- fake.emit.message(buildMessage({ id: "msg-stuck" }));
720
- await flushDispatchChain();
721
- fake.emit.message(buildMessage({ id: "msg-next" }));
722
- await new Promise((resolve) => setTimeout(resolve, 10));
723
- await flushDispatchChain();
724
- expect(received).toEqual([message("msg-next")]);
725
- expect(warnSpy).toHaveBeenCalledWith(expect.objectContaining({
726
- messageId: message("msg-stuck"),
727
- leaseId: `lease-${message("msg-stuck")}`,
728
- timeoutMs: 1,
729
- }), "MoltZapChannelCore: inbound dispatch lease expired");
730
- });
731
- it("serializes handlers so message order is preserved across async resolution", async () => {
732
- const { fake, received } = customSetup();
733
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
734
- forceResolveAgentNamePath(fake);
735
- // Hold the resolveAgentName promises so we can control timing. The
736
- // fake returns an async-style Effect that resumes once the test calls
737
- // the recorded resolver — this mirrors the pre-Effect Promise flow.
738
- const resolvers = [];
739
- fake.service.resolveAgentName = (id) => Effect.async((resume) => {
740
- resolvers.push(() => resume(Effect.succeed(id)));
741
- });
742
- fake.emit.message(buildMessage({ id: "msg-1" }));
743
- fake.emit.message(buildMessage({ id: "msg-2" }));
744
- // Neither has been delivered to the handler yet — first message is
745
- // still awaiting resolveAgentName; second is queued behind it.
746
- await flushDispatchChain();
747
- expect(received).toHaveLength(0);
748
- expect(resolvers).toHaveLength(1);
749
- // Resolve the first, chain advances.
750
- resolvers[0]("agent-alice");
751
- await flushDispatchChain();
752
- expect(received.map((r) => r.id)).toEqual([message("msg-1")]);
753
- expect(resolvers).toHaveLength(2);
754
- // Resolve the second.
755
- resolvers[1]("agent-bob");
756
- await flushDispatchChain();
757
- expect(received.map((r) => r.id)).toEqual([
758
- message("msg-1"),
759
- message("msg-2"),
760
- ]);
761
- });
762
- it("awaits async handler fully before processing the next message", async () => {
763
- const { fake, core } = customSetup();
764
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
765
- fake.state.setAgentName("agent-alice", "Alice");
766
- const handlerBarriers = [];
767
- const order = [];
768
- core.onInbound((m) => Effect.gen(function* () {
769
- order.push(`enter:${m.id}`);
770
- yield* Effect.async((resume) => {
771
- handlerBarriers.push(() => resume(Effect.void));
772
- });
773
- order.push(`exit:${m.id}`);
774
- }));
775
- fake.emit.message(buildMessage({ id: "msg-1" }));
776
- fake.emit.message(buildMessage({ id: "msg-2" }));
777
- await flushDispatchChain();
778
- // Handler started for msg-1, hasn't returned yet. msg-2 has NOT entered.
779
- expect(order).toEqual([`enter:${message("msg-1")}`]);
780
- handlerBarriers[0]();
781
- await flushDispatchChain();
782
- // msg-1 fully processed; msg-2 has entered.
783
- expect(order).toEqual([
784
- `enter:${message("msg-1")}`,
785
- `exit:${message("msg-1")}`,
786
- `enter:${message("msg-2")}`,
787
- ]);
788
- handlerBarriers[1]();
789
- await flushDispatchChain();
790
- expect(order).toEqual([
791
- `enter:${message("msg-1")}`,
792
- `exit:${message("msg-1")}`,
793
- `enter:${message("msg-2")}`,
794
- `exit:${message("msg-2")}`,
795
- ]);
796
- });
797
- it("onInbound replaces the previous handler instead of adding", async () => {
798
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
799
- fake.state.setAgentName("agent-alice", "Alice");
800
- const firstHandler = vi.fn();
801
- const secondHandler = vi.fn();
802
- core.onInbound((m) => Effect.sync(() => firstHandler(m)));
803
- core.onInbound((m) => Effect.sync(() => secondHandler(m)));
804
- fake.emit.message(buildMessage());
805
- await flushDispatchChain();
806
- expect(firstHandler).not.toHaveBeenCalled();
807
- expect(secondHandler).toHaveBeenCalledOnce();
808
- });
98
+ }
99
+ effectTest("resolves sender name from getAgentName cache when present", resolvesSenderNameFromGetAgentNameCacheWhenPresent);
100
+ function fallsBackToResolveAgentNameWhenGetAgentNameReturnsUndefined() {
101
+ return Effect.gen(function* () {
102
+ const { fake, received } = customSetup();
103
+ forceResolveAgentNamePath(fake);
104
+ fake.state.setConversation("conv-1", { type: "dm", participants: [] });
105
+ fake.state.setAgentName("agent-alice", ALICE_RESOLVED_NAME);
106
+ fake.emit.message(buildMessage());
107
+ yield* flushDispatchChainEffect;
108
+ expect(received[0].sender.name).toBe(ALICE_RESOLVED_NAME);
109
+ expect(fake.state.resolveAgentNameCallCount("agent-alice")).toBe(1);
809
110
  });
810
- describe("context blocks enrichment", () => {
811
- it("attaches groupMetadata when conversation is a group", async () => {
812
- fake.state.setConversation("conv-1", {
813
- type: "group",
814
- name: "devs",
815
- participants: [
816
- "agent:agent-alice",
817
- "agent:agent-bob",
818
- "agent:agent-self",
819
- ],
820
- });
821
- fake.state.setAgentName("agent-alice", "Alice");
822
- fake.emit.message(buildMessage());
823
- await flushDispatchChain();
824
- const msg = inbound[0];
825
- expect(msg.contextBlocks.groupMetadata).toEqual({
826
- type: "group",
827
- name: "devs",
828
- participants: [
829
- participant("agent-alice"),
830
- participant("agent-bob"),
831
- participant("agent-self"),
832
- ],
833
- });
834
- });
835
- it("does NOT attach groupMetadata for DM conversations", async () => {
836
- fake.state.setConversation("conv-1", {
837
- type: "dm",
838
- name: "alice-dm",
839
- participants: ["agent:agent-alice", "agent:agent-self"],
840
- });
841
- fake.state.setAgentName("agent-alice", "Alice");
842
- fake.emit.message(buildMessage());
843
- await flushDispatchChain();
844
- expect(inbound[0].contextBlocks.groupMetadata).toBeUndefined();
845
- });
846
- it("attaches crossConversation entries when getContextEntries returns non-empty", async () => {
847
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
848
- fake.state.setAgentName("agent-alice", "Alice");
849
- const entries = [
850
- {
851
- conversationId: "conv-other",
852
- conversationName: "other-dm",
853
- senderName: "Bob",
854
- text: "hello from the other side",
855
- minutesAgo: 3,
856
- count: 1,
857
- },
858
- ];
859
- fake.state.setContextEntries("conv-1", entries);
860
- fake.emit.message(buildMessage());
861
- await flushDispatchChain();
862
- expect(inbound[0].contextBlocks.crossConversation).toEqual(entries);
863
- });
864
- it("does NOT attach crossConversation when getContextEntries returns empty", async () => {
865
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
866
- fake.state.setAgentName("agent-alice", "Alice");
867
- // Fixture default: returns [] for unknown convs
868
- fake.emit.message(buildMessage());
869
- await flushDispatchChain();
870
- expect(inbound[0].contextBlocks.crossConversation).toBeUndefined();
871
- });
872
- it("handles groups with zero participants gracefully", async () => {
873
- fake.state.setConversation("conv-1", {
874
- type: "group",
875
- name: "empty-group",
876
- participants: [],
877
- });
878
- fake.state.setAgentName("agent-alice", "Alice");
879
- fake.emit.message(buildMessage());
880
- await flushDispatchChain();
881
- const meta = inbound[0].contextBlocks.groupMetadata;
882
- expect(meta).toBeDefined();
883
- expect(meta.participants).toEqual([]);
884
- });
885
- it("commits context markers after enrichment so a second inbound message does not re-see the same entries", async () => {
886
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
887
- fake.state.setAgentName("agent-alice", "Alice");
888
- fake.state.setContextEntries("conv-1", [
889
- {
890
- conversationId: "conv-other",
891
- senderName: "Bob",
892
- text: "first visit",
893
- minutesAgo: 1,
894
- count: 1,
895
- },
896
- ]);
897
- fake.emit.message(buildMessage({ id: "msg-1" }));
898
- await flushDispatchChain();
899
- expect(inbound[0].contextBlocks.crossConversation).toHaveLength(1);
900
- fake.emit.message(buildMessage({ id: "msg-2" }));
901
- await flushDispatchChain();
902
- expect(inbound[1].contextBlocks.crossConversation).toBeUndefined();
903
- });
904
- it("does not commit when there are no context entries", async () => {
905
- const commitSpy = vi.fn();
906
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
907
- fake.state.setAgentName("agent-alice", "Alice");
908
- // Install a peekContextEntries that records commit calls.
909
- fake.service.peekContextEntries = () => ({ entries: [], commit: commitSpy });
910
- fake.emit.message(buildMessage());
911
- await flushDispatchChain();
912
- expect(commitSpy).not.toHaveBeenCalled();
913
- });
914
- it("attaches crossConversationMessages when peekFullMessages returns non-empty", async () => {
915
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
916
- fake.state.setAgentName("agent-alice", "Alice");
917
- fake.state.setFullMessages("conv-1", [
918
- {
919
- conversationId: "conv-other",
920
- conversationName: "other-dm",
921
- senderName: "Bob",
922
- senderId: "agent-bob",
923
- text: "full message text here",
924
- timestamp: "2026-04-13T22:00:00Z",
925
- },
926
- ]);
927
- fake.emit.message(buildMessage());
928
- await flushDispatchChain();
929
- const msgs = inbound[0].contextBlocks.crossConversationMessages;
930
- expect(msgs).toHaveLength(1);
931
- expect(msgs[0]).toMatchObject({
932
- conversationId: "conv-other",
933
- senderName: "Bob",
934
- text: "full message text here",
935
- timestamp: "2026-04-13T22:00:00Z",
936
- });
937
- });
938
- it("does NOT attach crossConversationMessages when peekFullMessages returns empty", async () => {
939
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
940
- fake.state.setAgentName("agent-alice", "Alice");
941
- fake.emit.message(buildMessage());
942
- await flushDispatchChain();
943
- expect(inbound[0].contextBlocks.crossConversationMessages).toBeUndefined();
944
- });
945
- it("commits full message markers after inbound handler succeeds", async () => {
946
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
947
- fake.state.setAgentName("agent-alice", "Alice");
948
- fake.state.setFullMessages("conv-1", [
949
- {
950
- conversationId: "conv-other",
951
- senderName: "Bob",
952
- senderId: "agent-bob",
953
- text: "first",
954
- timestamp: "2026-04-13T22:00:00Z",
955
- },
956
- ]);
957
- fake.emit.message(buildMessage({ id: "msg-1" }));
958
- await flushDispatchChain();
959
- expect(inbound[0].contextBlocks.crossConversationMessages).toHaveLength(1);
960
- fake.emit.message(buildMessage({ id: "msg-2" }));
961
- await flushDispatchChain();
962
- expect(inbound[1].contextBlocks.crossConversationMessages).toBeUndefined();
963
- });
111
+ }
112
+ effectTest("falls back to resolveAgentName when getAgentName returns undefined", fallsBackToResolveAgentNameWhenGetAgentNameReturnsUndefined);
113
+ function fallsBackToSenderIdWhenBothNameLookupsFail() {
114
+ return Effect.gen(function* () {
115
+ const { fake, received } = customSetup();
116
+ forceResolveAgentNamePath(fake);
117
+ fake.state.setConversation("conv-1", { type: "dm", participants: [] });
118
+ fake.emit.message(buildMessage({ senderId: "agent-unknown" }));
119
+ yield* flushDispatchChainEffect;
120
+ expect(received[0].sender.name).toBe(agent("agent-unknown"));
964
121
  });
965
- describe("sendReply", () => {
966
- it("delegates to service.send with conversationId and text", async () => {
967
- await Effect.runPromise(core.sendReply(conversation("conv-42"), "hello there"));
968
- expect(fake.state.sent).toEqual([
969
- { convId: conversation("conv-42"), text: "hello there" },
970
- ]);
971
- });
972
- it("drops replies locally after the conversation is archived", async () => {
973
- fake.emit.conversationArchived({ conversationId: "conv-42" });
974
- await Effect.runPromise(core.sendReply(conversation("conv-42"), "hello there"));
975
- expect(fake.state.sent).toEqual([]);
976
- });
977
- it("resumes replies after the conversation is unarchived", async () => {
978
- fake.emit.conversationArchived({ conversationId: "conv-42" });
979
- fake.emit.conversationUnarchived({ conversationId: "conv-42" });
980
- await Effect.runPromise(core.sendReply(conversation("conv-42"), "hello again"));
981
- expect(fake.state.sent).toEqual([
982
- { convId: conversation("conv-42"), text: "hello again" },
983
- ]);
984
- });
122
+ }
123
+ effectTest("falls back to sender.id when both name lookups fail", fallsBackToSenderIdWhenBothNameLookupsFail);
124
+ function swallowsResolveAgentNameErrorsAndFallsBackToSenderId() {
125
+ return Effect.gen(function* () {
126
+ const { fake, received } = customSetup();
127
+ forceResolveAgentNamePath(fake);
128
+ fake.state.setConversation("conv-1", { type: "dm", participants: [] });
129
+ fake.state.setResolveAgentNameFailure("agent-broken", new Error("network down"));
130
+ fake.emit.message(buildMessage({ senderId: "agent-broken" }));
131
+ yield* flushDispatchChainEffect;
132
+ expect(received[0].sender.name).toBe(agent("agent-broken"));
985
133
  });
986
- describe("static enrichMessage", () => {
987
- it("returns the same shape as the instance handler path", async () => {
988
- fake.state.setConversation("conv-1", {
989
- type: "group",
990
- name: "devs",
991
- participants: ["agent:agent-alice", "agent:agent-self"],
992
- });
993
- fake.state.setAgentName("agent-alice", "Alice");
994
- fake.state.setContextEntries("conv-1", [
995
- {
996
- conversationId: "conv-other",
997
- senderName: "Bob",
998
- text: "bonjour",
999
- minutesAgo: 1,
1000
- count: 1,
1001
- },
1002
- ]);
1003
- const msg = buildMessage({
1004
- id: "msg-static",
1005
- conversationId: "conv-1",
1006
- parts: [{ type: "text", text: "static enrichment" }],
1007
- });
1008
- const { enriched: staticResult, commitContext } = await Effect.runPromise(MoltZapChannelCore.enrichMessage(service, msg));
1009
- expect(staticResult).toMatchObject({
1010
- id: message("msg-static"),
1011
- conversationId: conversation("conv-1"),
1012
- sender: { id: agent("agent-alice"), name: "Alice" },
1013
- text: "static enrichment",
1014
- isFromMe: false,
1015
- });
1016
- expect(staticResult.contextBlocks.groupMetadata?.name).toBe("devs");
1017
- expect(staticResult.contextBlocks.crossConversation).toHaveLength(1);
1018
- expect(commitContext).toBeTypeOf("function");
1019
- });
1020
- it("static helper tolerates resolveAgentName throwing (disconnected service)", async () => {
1021
- const { fake } = customSetup();
1022
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
1023
- forceResolveAgentNamePath(fake);
1024
- fake.state.setResolveAgentNameFailure("agent-unknown", new Error("Not connected"));
1025
- const { enriched: result } = await Effect.runPromise(MoltZapChannelCore.enrichMessage(fake.service, buildMessage({ senderId: "agent-unknown" })));
1026
- expect(result.sender.name).toBe(agent("agent-unknown"));
1027
- });
134
+ }
135
+ effectTest("swallows resolveAgentName errors and falls back to sender.id", swallowsResolveAgentNameErrorsAndFallsBackToSenderId);
136
+ function concatenatesMultiTextPartMessagesWithNewlines() {
137
+ return Effect.gen(function* () {
138
+ fake.state.setConversation("conv-1", { type: "dm", participants: [] });
139
+ fake.state.setAgentName("agent-alice", "Alice");
140
+ fake.emit.message(buildMessage({
141
+ parts: [
142
+ { type: "text", text: "line one" },
143
+ { type: "text", text: "line two" },
144
+ ],
145
+ }));
146
+ yield* flushDispatchChainEffect;
147
+ expect(inbound[0].text).toBe(MULTILINE_TEXT);
1028
148
  });
1029
- describe("fanout guards", () => {
1030
- it("disconnect: running handlers continue after one throws; logger.error sees 'disconnect handler threw'", async () => {
1031
- const { fake, core, errorSpy } = customSetup();
1032
- const recorded = [];
1033
- core.onDisconnect(() => {
1034
- throw new Error("first disconnect boom");
1035
- });
1036
- core.onDisconnect(() => {
1037
- recorded.push("second");
1038
- });
1039
- await Effect.runPromise(core.connect());
1040
- fake.emit.disconnect();
1041
- expect(recorded).toEqual(["second"]);
1042
- expect(errorSpy).toHaveBeenCalled();
1043
- const msgs = errorSpy.mock.calls.map((c) => (typeof c[1] === "string" ? c[1] : c[0]));
1044
- expect(msgs.some((m) => typeof m === "string" && m.includes("disconnect handler threw"))).toBe(true);
1045
- });
1046
- it("reconnect: running handlers continue after one throws; logger.error sees 'reconnect handler threw'", () => {
1047
- const { fake, core, errorSpy } = customSetup();
1048
- const recorded = [];
1049
- core.onReconnect(() => {
1050
- throw new Error("first reconnect boom");
1051
- });
1052
- core.onReconnect(() => {
1053
- recorded.push("second");
1054
- });
1055
- fake.emit.reconnect();
1056
- expect(recorded).toEqual(["second"]);
1057
- const msgs = errorSpy.mock.calls.map((c) => (typeof c[1] === "string" ? c[1] : c[0]));
1058
- expect(msgs.some((m) => typeof m === "string" && m.includes("reconnect handler threw"))).toBe(true);
1059
- });
149
+ }
150
+ effectTest("concatenates multi-text-part messages with newlines", concatenatesMultiTextPartMessagesWithNewlines);
151
+ function ignoresNonTextPartsWhenBuildingText() {
152
+ return Effect.gen(function* () {
153
+ fake.state.setConversation("conv-1", { type: "dm", participants: [] });
154
+ fake.state.setAgentName("agent-alice", "Alice");
155
+ fake.emit.message(buildMessage({
156
+ parts: [
157
+ { type: "text", text: CAPTION_TEXT },
158
+ { type: "image", url: "https://example.com/pic.png" },
159
+ ],
160
+ }));
161
+ yield* flushDispatchChainEffect;
162
+ expect(inbound[0].text).toBe(CAPTION_TEXT);
1060
163
  });
1061
- describe("handleInbound does not commit context on handler failure", () => {
1062
- it("leaves markers unadvanced when the handler's Effect fails so the next message re-sees the same context entries", async () => {
1063
- const { fake, core } = customSetup();
1064
- fake.state.setConversation("conv-1", { type: "dm", participants: [] });
1065
- fake.state.setAgentName("agent-alice", "Alice");
1066
- fake.state.setContextEntries("conv-1", [
1067
- {
1068
- conversationId: "conv-other",
1069
- senderName: "Bob",
1070
- text: "first visit",
1071
- minutesAgo: 1,
1072
- count: 1,
1073
- },
1074
- ]);
1075
- let shouldFail = true;
1076
- const received = [];
1077
- core.onInbound((m) => Effect.gen(function* () {
1078
- received.push(m);
1079
- if (shouldFail) {
1080
- yield* Effect.fail(new Error("inbound handler boom"));
1081
- }
1082
- }));
1083
- // First message: handler fails after capturing the enriched payload.
1084
- // commitContext() must NOT run, so the fake's contextEntries remain.
1085
- fake.emit.message(buildMessage({ id: "msg-1" }));
1086
- await flushDispatchChain();
1087
- expect(received[0].contextBlocks.crossConversation).toHaveLength(1);
1088
- // Second message: handler succeeds. Because the first message didn't
1089
- // commit, the fake still returns the same entries.
1090
- shouldFail = false;
1091
- fake.emit.message(buildMessage({ id: "msg-2" }));
1092
- await flushDispatchChain();
1093
- expect(received[1].contextBlocks.crossConversation).toHaveLength(1);
1094
- expect(received[1].contextBlocks.crossConversation[0].text).toBe("first visit");
1095
- });
164
+ }
165
+ effectTest("ignores non-text parts when building text", ignoresNonTextPartsWhenBuildingText);
166
+ function setsIsFromMeTrueWhenSenderMatchesOwnAgentId() {
167
+ return Effect.gen(function* () {
168
+ fake.state.setConversation("conv-1", { type: "dm", participants: [] });
169
+ fake.emit.message(buildMessage({ senderId: "agent-self" }));
170
+ yield* flushDispatchChainEffect;
171
+ expect(inbound[0].isFromMe).toBe(true);
1096
172
  });
1097
- });
173
+ }
174
+ effectTest("sets isFromMe=true when sender matches ownAgentId", setsIsFromMeTrueWhenSenderMatchesOwnAgentId);
175
+ function forwardsReplyToIdFromTheMessageFrame() {
176
+ return Effect.gen(function* () {
177
+ fake.state.setConversation("conv-1", { type: "dm", participants: [] });
178
+ fake.state.setAgentName("agent-alice", "Alice");
179
+ fake.emit.message(buildMessage({ replyToId: "msg-parent-123" }));
180
+ yield* flushDispatchChainEffect;
181
+ expect(inbound[0].replyToId).toBe(message("msg-parent-123"));
182
+ });
183
+ }
184
+ effectTest("forwards replyToId from the message frame", forwardsReplyToIdFromTheMessageFrame);
185
+ function logsFailuresFromTheInboundHandlerSEffectErrorChannelAndKeepsTheConsumerAlive() {
186
+ return Effect.gen(function* () {
187
+ const { fake, core } = customSetup();
188
+ fake.state.setConversation("conv-1", { type: "dm", participants: [] });
189
+ fake.state.setAgentName("agent-alice", "Alice");
190
+ let handlerShouldFail = true;
191
+ const received = [];
192
+ // Replace the setup's default capture handler with one that can fail.
193
+ core.onInbound((m) => Effect.gen(function* () {
194
+ if (handlerShouldFail) {
195
+ yield* Effect.fail(new TestInboundHandlerError({ message: "handler boom" }));
196
+ }
197
+ received.push(m);
198
+ }));
199
+ fake.emit.message(buildMessage({ id: "msg-1" }));
200
+ yield* flushDispatchChainEffect;
201
+ expect(received).toHaveLength(0);
202
+ // Recovery: subsequent message lands cleanly.
203
+ handlerShouldFail = false;
204
+ fake.emit.message(buildMessage({ id: "msg-2" }));
205
+ yield* flushDispatchChainEffect;
206
+ expect(received).toHaveLength(1);
207
+ expect(received[0].id).toBe(message("msg-2"));
208
+ });
209
+ }
210
+ effectTest("logs failures from the inbound handler's Effect error channel and keeps the consumer alive", logsFailuresFromTheInboundHandlerSEffectErrorChannelAndKeepsTheConsumerAlive);
211
+ function logsSynchronousDefectsThrownFromInsideTheHandlerSEffect() {
212
+ return Effect.gen(function* () {
213
+ const { fake, core } = customSetup();
214
+ fake.state.setConversation("conv-1", { type: "dm", participants: [] });
215
+ fake.state.setAgentName("agent-alice", "Alice");
216
+ core.onInbound((_m) => Effect.sync(() => {
217
+ throw new Error("sync defect");
218
+ }));
219
+ fake.emit.message(buildMessage({ id: "msg-1" }));
220
+ yield* flushDispatchChainEffect;
221
+ // Consumer fiber survives a defect and continues to dispatch later messages.
222
+ const next = [];
223
+ core.onInbound((m) => Effect.sync(() => {
224
+ next.push(m);
225
+ }));
226
+ fake.emit.message(buildMessage({ id: "msg-2" }));
227
+ yield* flushDispatchChainEffect;
228
+ expect(next.map((r) => r.id)).toEqual([message("msg-2")]);
229
+ });
230
+ }
231
+ effectTest("logs synchronous defects thrown from inside the handler's Effect", logsSynchronousDefectsThrownFromInsideTheHandlerSEffect);
1098
232
  //# sourceMappingURL=channel-core.test.js.map