voicecc 1.2.13 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Integration tests for WhatsApp message round-trip and group sync.
3
+ *
4
+ * Tests the full message handling pipeline (message -> Python -> reply) and
5
+ * the group lifecycle when agents are created/deleted.
6
+ *
7
+ * Run: node --experimental-test-module-mocks --import tsx/esm --test server/services/whatsapp-integration.test.ts
8
+ */
9
+
10
+ import { describe, it, mock, beforeEach } from "node:test";
11
+ import { strict as assert } from "node:assert";
12
+
13
+ // ============================================================================
14
+ // MODULE MOCKS (must be set up before importing modules under test)
15
+ // ============================================================================
16
+
17
+ /** In-memory group mappings used by the mocked whatsapp-groups module */
18
+ const groupMappings: Map<string, { agentId: string; lastSessionId: string | null }> = new Map();
19
+
20
+ /** Tracks calls to sock.sendMessage */
21
+ const sendMessageCalls: Array<{ jid: string; content: { text: string } }> = [];
22
+
23
+ /** Tracks calls to sock.groupCreate */
24
+ const groupCreateCalls: Array<{ name: string; participants: string[] }> = [];
25
+
26
+ /** Tracks calls to sock.groupLeave */
27
+ const groupLeaveCalls: Array<{ jid: string }> = [];
28
+
29
+ /** Counter for generating unique group JIDs from groupCreate */
30
+ let groupCreateCounter = 0;
31
+
32
+ /** Mock Baileys socket */
33
+ const mockSocket = {
34
+ sendMessage: async (jid: string, content: { text: string }) => {
35
+ sendMessageCalls.push({ jid, content });
36
+ },
37
+ groupCreate: async (name: string, participants: string[]) => {
38
+ groupCreateCalls.push({ name, participants });
39
+ groupCreateCounter++;
40
+ return { id: `new-group-${groupCreateCounter}@g.us` };
41
+ },
42
+ groupLeave: async (jid: string) => {
43
+ groupLeaveCalls.push({ jid });
44
+ },
45
+ groupFetchAllParticipating: async () => ({}),
46
+ };
47
+
48
+ // Mock whatsapp-manager to return our mock socket
49
+ mock.module("./whatsapp-manager.js", {
50
+ namedExports: {
51
+ getSocket: () => mockSocket,
52
+ isConnected: () => true,
53
+ },
54
+ });
55
+
56
+ // Mock whatsapp-groups with in-memory state that we control
57
+ mock.module("./whatsapp-groups.js", {
58
+ namedExports: {
59
+ getAgentIdForGroup: (jid: string) => groupMappings.get(jid)?.agentId,
60
+ getLastSessionId: (jid: string) => groupMappings.get(jid)?.lastSessionId ?? null,
61
+ setLastSessionId: async (jid: string, sessionId: string) => {
62
+ const existing = groupMappings.get(jid);
63
+ if (existing) {
64
+ existing.lastSessionId = sessionId;
65
+ }
66
+ },
67
+ syncGroupsForNewAgent: async (agentId: string) => {
68
+ // Delegate to the real-ish logic using our mock socket
69
+ const name = `[VoiceCC] ${agentId}`;
70
+ const result = await mockSocket.groupCreate(name, []);
71
+ groupMappings.set(result.id, { agentId, lastSessionId: null });
72
+ },
73
+ syncGroupsForDeletedAgent: async (agentId: string) => {
74
+ for (const [jid, mapping] of groupMappings.entries()) {
75
+ if (mapping.agentId === agentId) {
76
+ await mockSocket.groupLeave(jid);
77
+ groupMappings.delete(jid);
78
+ return;
79
+ }
80
+ }
81
+ },
82
+ formatGroupName: (agentId: string) => `[VoiceCC] ${agentId}`,
83
+ loadMappings: async () => {},
84
+ saveMappings: async () => {},
85
+ findMappingByAgentId: (agentId: string) => {
86
+ for (const [jid, mapping] of groupMappings.entries()) {
87
+ if (mapping.agentId === agentId) {
88
+ return { groupJid: jid, ...mapping };
89
+ }
90
+ }
91
+ return undefined;
92
+ },
93
+ },
94
+ });
95
+
96
+ // Mock agent-store for the group sync tests
97
+ mock.module("./agent-store.js", {
98
+ namedExports: {
99
+ listAgents: async () => [],
100
+ },
101
+ });
102
+
103
+ const { handleIncomingMessage } = await import("./whatsapp-message-handler.js");
104
+ const {
105
+ syncGroupsForNewAgent,
106
+ syncGroupsForDeletedAgent,
107
+ } = await import("./whatsapp-groups.js");
108
+
109
+ // ============================================================================
110
+ // HELPERS
111
+ // ============================================================================
112
+
113
+ /** The original global fetch, used to restore after mocking */
114
+ const originalFetch = globalThis.fetch;
115
+
116
+ /**
117
+ * Build a mock SSE response from Python /chat/send.
118
+ *
119
+ * @param events - Array of SSE event payloads
120
+ * @param status - HTTP status code
121
+ * @returns A Response with SSE body
122
+ */
123
+ function buildPythonResponse(
124
+ events: Array<{ type: string; content?: string; sessionId?: string; toolName?: string }>,
125
+ status = 200
126
+ ): Response {
127
+ const sseText = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("");
128
+ const stream = new ReadableStream({
129
+ start(controller) {
130
+ controller.enqueue(new TextEncoder().encode(sseText));
131
+ controller.close();
132
+ },
133
+ });
134
+ return new Response(stream, {
135
+ status,
136
+ headers: { "Content-Type": "text/event-stream" },
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Parse the JSON body from a captured fetch call.
142
+ *
143
+ * @param fetchCall - The arguments passed to fetch
144
+ * @returns The parsed JSON body
145
+ */
146
+ async function parseFetchBody(fetchCall: [string, RequestInit]): Promise<Record<string, unknown>> {
147
+ const body = fetchCall[1].body as string;
148
+ return JSON.parse(body);
149
+ }
150
+
151
+ // ============================================================================
152
+ // TESTS: Full message round-trip
153
+ // ============================================================================
154
+
155
+ describe("handleIncomingMessage - message round-trip", () => {
156
+ const groupJid = "120363001234567890@g.us";
157
+
158
+ beforeEach(() => {
159
+ // Reset state
160
+ sendMessageCalls.length = 0;
161
+ groupMappings.clear();
162
+ groupMappings.set(groupJid, { agentId: "agent-1", lastSessionId: "prev-session-42" });
163
+ });
164
+
165
+ it("calls Python with correct params and sends reply back via Baileys", async (t) => {
166
+ // Track fetch calls
167
+ const fetchCalls: Array<[string, RequestInit]> = [];
168
+
169
+ // Mock global fetch to return a known SSE response
170
+ t.after(() => { globalThis.fetch = originalFetch; });
171
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
172
+ fetchCalls.push([url, init]);
173
+ return buildPythonResponse([
174
+ { type: "text_delta", content: "I am " },
175
+ { type: "text_delta", content: "agent-1." },
176
+ { type: "result", sessionId: "new-session-99" },
177
+ ]);
178
+ }) as typeof fetch;
179
+
180
+ await handleIncomingMessage({
181
+ groupJid,
182
+ senderJid: "5511999998888@s.whatsapp.net",
183
+ text: "Hello agent",
184
+ messageId: "msg-001",
185
+ });
186
+
187
+ // Verify Python was called with correct params
188
+ assert.equal(fetchCalls.length, 1);
189
+ const [url, _init] = fetchCalls[0]!;
190
+ assert.ok(url.endsWith("/chat/send"));
191
+
192
+ const body = await parseFetchBody(fetchCalls[0]!);
193
+ assert.equal(body.session_key, `wa:${groupJid}`);
194
+ assert.equal(body.agent_id, "agent-1");
195
+ assert.equal(body.resume_session_id, "prev-session-42");
196
+ assert.equal(body.text, "Hello agent");
197
+
198
+ // Verify reply was sent back via Baileys
199
+ assert.equal(sendMessageCalls.length, 1);
200
+ assert.equal(sendMessageCalls[0]!.jid, groupJid);
201
+ assert.equal(sendMessageCalls[0]!.content.text, "[voicecc] I am agent-1.");
202
+
203
+ // Verify session ID was stored
204
+ assert.equal(groupMappings.get(groupJid)?.lastSessionId, "new-session-99");
205
+ });
206
+
207
+ it("sends 'Still thinking' when Python returns HTTP 409", async (t) => {
208
+ t.after(() => { globalThis.fetch = originalFetch; });
209
+ globalThis.fetch = (async () => {
210
+ return new Response("Already streaming", { status: 409 });
211
+ }) as typeof fetch;
212
+
213
+ await handleIncomingMessage({
214
+ groupJid,
215
+ senderJid: "5511999998888@s.whatsapp.net",
216
+ text: "Another question",
217
+ messageId: "msg-002",
218
+ });
219
+
220
+ // Verify "Still thinking" reply was sent
221
+ assert.equal(sendMessageCalls.length, 1);
222
+ assert.equal(sendMessageCalls[0]!.jid, groupJid);
223
+ assert.equal(sendMessageCalls[0]!.content.text, "Still thinking, please wait...");
224
+
225
+ // Session ID should not have changed
226
+ assert.equal(groupMappings.get(groupJid)?.lastSessionId, "prev-session-42");
227
+ });
228
+
229
+ it("passes null resume_session_id when no previous session exists", async (t) => {
230
+ // Clear the previous session
231
+ groupMappings.set(groupJid, { agentId: "agent-1", lastSessionId: null });
232
+
233
+ const fetchCalls: Array<[string, RequestInit]> = [];
234
+ t.after(() => { globalThis.fetch = originalFetch; });
235
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
236
+ fetchCalls.push([url, init]);
237
+ return buildPythonResponse([
238
+ { type: "text_delta", content: "First reply" },
239
+ { type: "result", sessionId: "first-session" },
240
+ ]);
241
+ }) as typeof fetch;
242
+
243
+ await handleIncomingMessage({
244
+ groupJid,
245
+ senderJid: "5511999998888@s.whatsapp.net",
246
+ text: "First message",
247
+ messageId: "msg-003",
248
+ });
249
+
250
+ const body = await parseFetchBody(fetchCalls[0]!);
251
+ assert.equal(body.resume_session_id, null);
252
+ });
253
+
254
+ it("sends multiple WhatsApp messages when response has tool boundaries", async (t) => {
255
+ t.after(() => { globalThis.fetch = originalFetch; });
256
+ globalThis.fetch = (async () => {
257
+ return buildPythonResponse([
258
+ { type: "text_delta", content: "Looking that up..." },
259
+ { type: "tool_start", toolName: "read_file" },
260
+ { type: "text_delta", content: "Here is the answer." },
261
+ { type: "result", sessionId: "new-session-99" },
262
+ ]);
263
+ }) as typeof fetch;
264
+
265
+ await handleIncomingMessage({
266
+ groupJid,
267
+ senderJid: "5511999998888@s.whatsapp.net",
268
+ text: "What does the config say?",
269
+ messageId: "msg-004",
270
+ });
271
+
272
+ // Two separate messages: one before tool, one after
273
+ assert.equal(sendMessageCalls.length, 2);
274
+ assert.equal(sendMessageCalls[0]!.jid, groupJid);
275
+ assert.equal(sendMessageCalls[0]!.content.text, "[voicecc] Looking that up...");
276
+ assert.equal(sendMessageCalls[1]!.jid, groupJid);
277
+ assert.equal(sendMessageCalls[1]!.content.text, "[voicecc] Here is the answer.");
278
+
279
+ // Session ID should be stored
280
+ assert.equal(groupMappings.get(groupJid)?.lastSessionId, "new-session-99");
281
+ });
282
+ });
283
+
284
+ // ============================================================================
285
+ // TESTS: Group sync on agent lifecycle
286
+ // ============================================================================
287
+
288
+ describe("Group sync on agent lifecycle", () => {
289
+ beforeEach(() => {
290
+ groupMappings.clear();
291
+ groupCreateCalls.length = 0;
292
+ groupLeaveCalls.length = 0;
293
+ sendMessageCalls.length = 0;
294
+ groupCreateCounter = 0;
295
+ });
296
+
297
+ it("syncGroupsForNewAgent creates a group with [VoiceCC] prefix and persists mapping", async () => {
298
+ await syncGroupsForNewAgent("sales-bot");
299
+
300
+ // Verify groupCreate was called with the correct name
301
+ assert.equal(groupCreateCalls.length, 1);
302
+ assert.equal(groupCreateCalls[0]!.name, "[VoiceCC] sales-bot");
303
+ assert.deepEqual(groupCreateCalls[0]!.participants, []);
304
+
305
+ // Verify mapping was persisted
306
+ const mapping = groupMappings.get("new-group-1@g.us");
307
+ assert.notEqual(mapping, undefined);
308
+ assert.equal(mapping!.agentId, "sales-bot");
309
+ assert.equal(mapping!.lastSessionId, null);
310
+ });
311
+
312
+ it("syncGroupsForDeletedAgent leaves the group and removes mapping", async () => {
313
+ // First create a mapping
314
+ groupMappings.set("existing-group@g.us", { agentId: "old-agent", lastSessionId: "sess-1" });
315
+
316
+ await syncGroupsForDeletedAgent("old-agent");
317
+
318
+ // Verify groupLeave was called
319
+ assert.equal(groupLeaveCalls.length, 1);
320
+ assert.equal(groupLeaveCalls[0]!.jid, "existing-group@g.us");
321
+
322
+ // Verify mapping was removed
323
+ assert.equal(groupMappings.has("existing-group@g.us"), false);
324
+ });
325
+
326
+ it("syncGroupsForDeletedAgent does nothing for unmapped agent", async () => {
327
+ await syncGroupsForDeletedAgent("nonexistent-agent");
328
+
329
+ assert.equal(groupLeaveCalls.length, 0);
330
+ });
331
+
332
+ it("creating then deleting an agent results in clean state", async () => {
333
+ // Create
334
+ await syncGroupsForNewAgent("temp-agent");
335
+ assert.equal(groupCreateCalls.length, 1);
336
+ assert.equal(groupMappings.size, 1);
337
+
338
+ // Delete
339
+ await syncGroupsForDeletedAgent("temp-agent");
340
+ assert.equal(groupLeaveCalls.length, 1);
341
+ assert.equal(groupMappings.size, 0);
342
+ });
343
+ });