@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.
- package/dist/auth.d.ts +12 -6
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +67 -29
- package/dist/auth.js.map +1 -1
- package/dist/auth.test.js +113 -76
- package/dist/auth.test.js.map +1 -1
- package/dist/channel-base/format-cross-conv.d.ts +43 -0
- package/dist/channel-base/format-cross-conv.d.ts.map +1 -0
- package/dist/channel-base/format-cross-conv.js +69 -0
- package/dist/channel-base/format-cross-conv.js.map +1 -0
- package/dist/channel-base/format-group-block.d.ts +43 -0
- package/dist/channel-base/format-group-block.d.ts.map +1 -0
- package/dist/channel-base/format-group-block.js +59 -0
- package/dist/channel-base/format-group-block.js.map +1 -0
- package/dist/channel-base/index.d.ts +21 -0
- package/dist/channel-base/index.d.ts.map +1 -0
- package/dist/channel-base/index.js +24 -0
- package/dist/channel-base/index.js.map +1 -0
- package/dist/channel-base/lease-guard.d.ts +30 -0
- package/dist/channel-base/lease-guard.d.ts.map +1 -0
- package/dist/channel-base/lease-guard.js +40 -0
- package/dist/channel-base/lease-guard.js.map +1 -0
- package/dist/channel-base/lease-store.d.ts +30 -0
- package/dist/channel-base/lease-store.d.ts.map +1 -0
- package/dist/channel-base/lease-store.js +58 -0
- package/dist/channel-base/lease-store.js.map +1 -0
- package/dist/channel-base/lease.d.ts +94 -0
- package/dist/channel-base/lease.d.ts.map +1 -0
- package/dist/channel-base/lease.js +110 -0
- package/dist/channel-base/lease.js.map +1 -0
- package/dist/channel-core-context.test.d.ts +2 -0
- package/dist/channel-core-context.test.d.ts.map +1 -0
- package/dist/channel-core-context.test.js +334 -0
- package/dist/channel-core-context.test.js.map +1 -0
- package/dist/channel-core-dispatch.test.d.ts +2 -0
- package/dist/channel-core-dispatch.test.d.ts.map +1 -0
- package/dist/channel-core-dispatch.test.js +550 -0
- package/dist/channel-core-dispatch.test.js.map +1 -0
- package/dist/channel-core-enrichment.d.ts +8 -0
- package/dist/channel-core-enrichment.d.ts.map +1 -0
- package/dist/channel-core-enrichment.js +134 -0
- package/dist/channel-core-enrichment.js.map +1 -0
- package/dist/channel-core-errors.d.ts +20 -0
- package/dist/channel-core-errors.d.ts.map +1 -0
- package/dist/channel-core-errors.js +12 -0
- package/dist/channel-core-errors.js.map +1 -0
- package/dist/channel-core-test-support.d.ts +48 -0
- package/dist/channel-core-test-support.d.ts.map +1 -0
- package/dist/channel-core-test-support.js +111 -0
- package/dist/channel-core-test-support.js.map +1 -0
- package/dist/channel-core.d.ts +30 -7
- package/dist/channel-core.d.ts.map +1 -1
- package/dist/channel-core.js +214 -237
- package/dist/channel-core.js.map +1 -1
- package/dist/channel-core.test.js +216 -1082
- package/dist/channel-core.test.js.map +1 -1
- package/dist/cli/__tests__/cli-multi-agent.int.test.js +129 -23
- package/dist/cli/__tests__/cli-multi-agent.int.test.js.map +1 -1
- package/dist/cli/commands/conversations-v2.test.js +64 -54
- package/dist/cli/commands/conversations-v2.test.js.map +1 -1
- package/dist/cli/commands/conversations.d.ts +5 -5
- package/dist/cli/commands/conversations.d.ts.map +1 -1
- package/dist/cli/commands/conversations.js +43 -36
- package/dist/cli/commands/conversations.js.map +1 -1
- package/dist/cli/commands/messages.d.ts +4 -4
- package/dist/cli/commands/messages.d.ts.map +1 -1
- package/dist/cli/commands/messages.js +4 -4
- package/dist/cli/commands/messages.js.map +1 -1
- package/dist/cli/commands/messages.test.js +61 -49
- package/dist/cli/commands/messages.test.js.map +1 -1
- package/dist/cli/commands/ping.d.ts +0 -9
- package/dist/cli/commands/ping.d.ts.map +1 -1
- package/dist/cli/commands/ping.js +1 -1
- package/dist/cli/commands/ping.js.map +1 -1
- package/dist/cli/commands/presence.d.ts +1 -1
- package/dist/cli/commands/presence.js +1 -1
- package/dist/cli/commands/register.d.ts +2 -2
- package/dist/cli/commands/register.d.ts.map +1 -1
- package/dist/cli/commands/register.js +98 -77
- package/dist/cli/commands/register.js.map +1 -1
- package/dist/cli/commands/register.test.js +63 -39
- package/dist/cli/commands/register.test.js.map +1 -1
- package/dist/cli/commands/send.d.ts +6 -6
- package/dist/cli/commands/send.js +6 -6
- package/dist/cli/commands/send.test.js +32 -28
- package/dist/cli/commands/send.test.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +3 -4
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/config.d.ts +0 -2
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +86 -76
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/config.test.js +80 -42
- package/dist/cli/config.test.js.map +1 -1
- package/dist/cli/index.d.ts +1 -16
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +50 -113
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/process-argv.d.ts +2 -0
- package/dist/cli/process-argv.d.ts.map +1 -0
- package/dist/cli/process-argv.js +2 -0
- package/dist/cli/process-argv.js.map +1 -0
- package/dist/cli/profile.d.ts +3 -10
- package/dist/cli/profile.d.ts.map +1 -1
- package/dist/cli/profile.js +105 -104
- package/dist/cli/profile.js.map +1 -1
- package/dist/cli/profile.test.js +338 -232
- package/dist/cli/profile.test.js.map +1 -1
- package/dist/cli/runtime.d.ts +5 -17
- package/dist/cli/runtime.d.ts.map +1 -1
- package/dist/cli/runtime.js +6 -59
- package/dist/cli/runtime.js.map +1 -1
- package/dist/cli/socket-client.d.ts +10 -15
- package/dist/cli/socket-client.d.ts.map +1 -1
- package/dist/cli/socket-client.js +74 -89
- package/dist/cli/socket-client.js.map +1 -1
- package/dist/cli/transport.d.ts +9 -5
- package/dist/cli/transport.d.ts.map +1 -1
- package/dist/cli/transport.js +45 -42
- package/dist/cli/transport.js.map +1 -1
- package/dist/cli/transport.test.js +227 -133
- package/dist/cli/transport.test.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/local-daemon-rpc.d.ts +13 -0
- package/dist/local-daemon-rpc.d.ts.map +1 -0
- package/dist/local-daemon-rpc.js +17 -0
- package/dist/local-daemon-rpc.js.map +1 -0
- package/dist/local-paths.d.ts +7 -0
- package/dist/local-paths.d.ts.map +1 -0
- package/dist/local-paths.js +20 -0
- package/dist/local-paths.js.map +1 -0
- package/dist/runtime/close-info.d.ts +3 -12
- package/dist/runtime/close-info.d.ts.map +1 -1
- package/dist/runtime/close-info.js +28 -39
- package/dist/runtime/close-info.js.map +1 -1
- package/dist/runtime/close-info.test.js +77 -70
- package/dist/runtime/close-info.test.js.map +1 -1
- package/dist/runtime/errors.test.js +42 -28
- package/dist/runtime/errors.test.js.map +1 -1
- package/dist/runtime/frame.d.ts +4 -31
- package/dist/runtime/frame.d.ts.map +1 -1
- package/dist/runtime/frame.js +7 -116
- package/dist/runtime/frame.js.map +1 -1
- package/dist/runtime/frame.test.js +41 -54
- package/dist/runtime/frame.test.js.map +1 -1
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +7 -11
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/local-history.d.ts +67 -0
- package/dist/runtime/local-history.d.ts.map +1 -0
- package/dist/runtime/local-history.js +77 -0
- package/dist/runtime/local-history.js.map +1 -0
- package/dist/runtime/local-service-commands.d.ts +19 -0
- package/dist/runtime/local-service-commands.d.ts.map +1 -1
- package/dist/runtime/local-service-commands.js +23 -0
- package/dist/runtime/local-service-commands.js.map +1 -1
- package/dist/runtime/local-socket-server.d.ts +20 -0
- package/dist/runtime/local-socket-server.d.ts.map +1 -0
- package/dist/runtime/local-socket-server.js +94 -0
- package/dist/runtime/local-socket-server.js.map +1 -0
- package/dist/runtime/refs.d.ts +1 -1
- package/dist/runtime/refs.js +1 -1
- package/dist/runtime/runtime.test.js +29 -21
- package/dist/runtime/runtime.test.js.map +1 -1
- package/dist/runtime/service-helpers.d.ts +32 -0
- package/dist/runtime/service-helpers.d.ts.map +1 -0
- package/dist/runtime/service-helpers.js +85 -0
- package/dist/runtime/service-helpers.js.map +1 -0
- package/dist/runtime/subscribers.d.ts +13 -17
- package/dist/runtime/subscribers.d.ts.map +1 -1
- package/dist/runtime/subscribers.js +37 -32
- package/dist/runtime/subscribers.js.map +1 -1
- package/dist/runtime/subscribers.test.js +139 -131
- package/dist/runtime/subscribers.test.js.map +1 -1
- package/dist/service-socket-path.test.d.ts +2 -0
- package/dist/service-socket-path.test.d.ts.map +1 -0
- package/dist/service-socket-path.test.js +87 -0
- package/dist/service-socket-path.test.js.map +1 -0
- package/dist/service.d.ts +23 -20
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +192 -340
- package/dist/service.js.map +1 -1
- package/dist/service.test.js +652 -653
- package/dist/service.test.js.map +1 -1
- package/dist/test/index.d.ts +30 -14
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +25 -21
- package/dist/test/index.js.map +1 -1
- package/dist/test-utils/channel-service-fixture.d.ts +1 -0
- package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
- package/dist/test-utils/channel-service-fixture.js +139 -98
- package/dist/test-utils/channel-service-fixture.js.map +1 -1
- package/dist/test-utils/conformance-adapter.d.ts.map +1 -1
- package/dist/test-utils/conformance-adapter.js +145 -124
- package/dist/test-utils/conformance-adapter.js.map +1 -1
- package/dist/test-utils/fake-service.d.ts.map +1 -1
- package/dist/test-utils/fake-service.js +3 -3
- package/dist/test-utils/fake-service.js.map +1 -1
- package/dist/test-utils/index.d.ts +25 -0
- package/dist/test-utils/index.d.ts.map +1 -1
- package/dist/test-utils/index.js +25 -8
- package/dist/test-utils/index.js.map +1 -1
- package/dist/ws-client-test-support.d.ts +180 -0
- package/dist/ws-client-test-support.d.ts.map +1 -0
- package/dist/ws-client-test-support.js +399 -0
- package/dist/ws-client-test-support.js.map +1 -0
- package/dist/ws-client.d.ts +59 -32
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +272 -321
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-client.test.js +388 -800
- package/dist/ws-client.test.js.map +1 -1
- package/package.json +18 -10
|
@@ -1,1098 +1,232 @@
|
|
|
1
|
-
import { beforeEach,
|
|
1
|
+
import { beforeEach, expect, it, vi } from "vitest";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
105
|
-
function
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
core
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
});
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
{
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|