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,395 @@
1
+ /**
2
+ * Baileys WhatsApp socket lifecycle manager.
3
+ *
4
+ * Manages the connection to WhatsApp via the Baileys library, including
5
+ * QR code generation for first-time linking, credential storage, reconnection
6
+ * with exponential backoff, and permanent credential revocation handling.
7
+ *
8
+ * Responsibilities:
9
+ * - Initialize and manage the Baileys socket connection
10
+ * - Generate QR codes for first-time WhatsApp linking
11
+ * - Store credentials at ~/.voicecc/whatsapp/ via useMultiFileAuthState
12
+ * - Reconnect with exponential backoff on temporary disconnects
13
+ * - Delete credentials and reset on permanent revocation (401/515)
14
+ * - Trigger group sync on successful connection
15
+ * - Forward incoming messages to the message handler
16
+ */
17
+
18
+ import makeWASocket, {
19
+ useMultiFileAuthState,
20
+ DisconnectReason,
21
+ makeCacheableSignalKeyStore,
22
+ Browsers,
23
+ type WASocket,
24
+ type ConnectionState,
25
+ proto,
26
+ } from "baileys";
27
+ import { join } from "node:path";
28
+ import { homedir } from "node:os";
29
+ import { rmSync, existsSync, mkdirSync } from "node:fs";
30
+ import { syncAllGroups } from "./whatsapp-groups.js"; // Created in Phase 3
31
+ import { shouldHandleMessage, handleIncomingMessage } from "./whatsapp-message-handler.js"; // Created in Phase 4
32
+
33
+ // ============================================================================
34
+ // CONSTANTS
35
+ // ============================================================================
36
+
37
+ /** Directory where Baileys stores auth credentials */
38
+ const CREDENTIALS_DIR = join(process.env.VOICECC_DIR ?? join(homedir(), ".voicecc"), "whatsapp");
39
+
40
+ /**
41
+ * WhatsApp Web protocol version to use.
42
+ * Baileys hardcodes an outdated version that WA servers reject with 405.
43
+ * This must be updated when WhatsApp changes their accepted version range.
44
+ * See: https://github.com/WhiskeySockets/Baileys/issues/2376
45
+ */
46
+ const WA_VERSION: [number, number, number] = [2, 3000, 1034074495];
47
+
48
+ /** Initial reconnect delay in milliseconds */
49
+ const RECONNECT_BASE_DELAY_MS = 2_000;
50
+
51
+ /** Maximum reconnect delay in milliseconds (2 minutes) */
52
+ const RECONNECT_MAX_DELAY_MS = 120_000;
53
+
54
+ /** Disconnect status codes that indicate permanent credential revocation (delete creds) */
55
+ const PERMANENT_DISCONNECT_CODES = [
56
+ DisconnectReason.loggedOut, // 401 -- user logged out from phone
57
+ ];
58
+
59
+ /** Disconnect status codes that require a reconnect (keep creds) */
60
+ const RECONNECT_DISCONNECT_CODES = [
61
+ DisconnectReason.restartRequired, // 515 -- expected after pairing, also periodic server restart
62
+ ];
63
+
64
+ // ============================================================================
65
+ // TYPES
66
+ // ============================================================================
67
+
68
+ /** Connection state exposed to the dashboard and other modules */
69
+ export interface WhatsAppConnectionState {
70
+ status: "disconnected" | "qr_pending" | "connecting" | "connected";
71
+ qrCode: string | null;
72
+ }
73
+
74
+ // ============================================================================
75
+ // STATE
76
+ // ============================================================================
77
+
78
+ /** Current connection state */
79
+ let connectionState: WhatsAppConnectionState = {
80
+ status: "disconnected",
81
+ qrCode: null,
82
+ };
83
+
84
+ /** Active Baileys socket instance */
85
+ let socket: WASocket | null = null;
86
+
87
+ /** Current reconnect attempt count (resets on successful connection) */
88
+ let reconnectAttempt = 0;
89
+
90
+ /** Timer reference for pending reconnect */
91
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
92
+
93
+ /** Whether stopWhatsApp was called intentionally (prevents auto-reconnect) */
94
+ let manuallyStopped = false;
95
+
96
+ /** Whether we have ever successfully connected in this session (connection === "open") */
97
+ let hasBeenConnected = false;
98
+
99
+ // ============================================================================
100
+ // MAIN HANDLERS
101
+ // ============================================================================
102
+
103
+ /**
104
+ * Initialize the Baileys socket and connect to WhatsApp.
105
+ * On first connect (no stored credentials), enters QR pending state.
106
+ * On successful connection, triggers group sync.
107
+ *
108
+ * @returns Resolves when the socket is initialized (not necessarily connected)
109
+ */
110
+ export async function startWhatsApp(): Promise<void> {
111
+ if (socket) {
112
+ throw new Error("WhatsApp is already running");
113
+ }
114
+
115
+ manuallyStopped = false;
116
+ reconnectAttempt = 0;
117
+ hasBeenConnected = false;
118
+
119
+ await createSocket();
120
+ }
121
+
122
+ /**
123
+ * Close the Baileys socket and set state to disconnected.
124
+ * Prevents automatic reconnection.
125
+ */
126
+ export function stopWhatsApp(): void {
127
+ manuallyStopped = true;
128
+ clearReconnectTimer();
129
+ destroySocket();
130
+ connectionState = { status: "disconnected", qrCode: null };
131
+ console.log("WhatsApp connection stopped.");
132
+ }
133
+
134
+ /**
135
+ * Get the current WhatsApp connection state.
136
+ *
137
+ * @returns Current status and QR code (if pending)
138
+ */
139
+ export function getConnectionState(): WhatsAppConnectionState {
140
+ return { ...connectionState };
141
+ }
142
+
143
+ /**
144
+ * Get the active Baileys socket for sending messages.
145
+ *
146
+ * @returns The active socket, or null if not connected
147
+ */
148
+ export function getSocket(): WASocket | null {
149
+ if (connectionState.status !== "connected") {
150
+ return null;
151
+ }
152
+ return socket;
153
+ }
154
+
155
+ /**
156
+ * Check if WhatsApp is currently connected.
157
+ *
158
+ * @returns True if connected
159
+ */
160
+ export function isConnected(): boolean {
161
+ return connectionState.status === "connected";
162
+ }
163
+
164
+ // ============================================================================
165
+ // HELPER FUNCTIONS
166
+ // ============================================================================
167
+
168
+ /**
169
+ * Create and configure a new Baileys socket with auth state and event handlers.
170
+ * Sets up connection.update and messages.upsert event listeners.
171
+ */
172
+ async function createSocket(): Promise<void> {
173
+ // Ensure credentials directory exists
174
+ mkdirSync(CREDENTIALS_DIR, { recursive: true });
175
+
176
+ const { state, saveCreds } = await useMultiFileAuthState(CREDENTIALS_DIR);
177
+
178
+ connectionState = { status: "connecting", qrCode: null };
179
+
180
+ const sock = makeWASocket({
181
+ version: WA_VERSION,
182
+ auth: {
183
+ creds: state.creds,
184
+ keys: makeCacheableSignalKeyStore(state.keys),
185
+ },
186
+ browser: Browsers.macOS("VoiceCC"),
187
+ printQRInTerminal: false,
188
+ });
189
+
190
+ socket = sock;
191
+
192
+ // Save credentials whenever they are updated
193
+ sock.ev.on("creds.update", saveCreds);
194
+
195
+ // Handle connection state changes
196
+ sock.ev.on("connection.update", (update: Partial<ConnectionState>) => {
197
+ handleConnectionUpdate(update, sock);
198
+ });
199
+
200
+ // Handle incoming messages
201
+ sock.ev.on("messages.upsert", (upsert) => {
202
+ handleMessagesUpsert(upsert, sock);
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Handle Baileys connection.update events.
208
+ * Manages QR code display, successful connections, and disconnects.
209
+ *
210
+ * @param update - Partial connection state from Baileys
211
+ */
212
+ function handleConnectionUpdate(update: Partial<ConnectionState>, sock: WASocket): void {
213
+ const { connection, qr, lastDisconnect } = update;
214
+
215
+ // QR code received -- user needs to scan
216
+ if (qr) {
217
+ connectionState = { status: "qr_pending", qrCode: qr };
218
+ console.log("WhatsApp QR code generated. Scan it from the dashboard.");
219
+ return;
220
+ }
221
+
222
+ if (connection === "open") {
223
+ // Restore the socket reference (may have been nulled during a temporary close)
224
+ socket = sock;
225
+ connectionState = { status: "connected", qrCode: null };
226
+ reconnectAttempt = 0;
227
+ hasBeenConnected = true;
228
+ console.log("WhatsApp connected successfully.");
229
+
230
+ // Trigger group sync for all agents
231
+ syncAllGroups().catch((err: unknown) => {
232
+ console.error(`WhatsApp group sync failed: ${err}`);
233
+ });
234
+ return;
235
+ }
236
+
237
+ if (connection === "close") {
238
+ const statusCode = extractDisconnectStatusCode(lastDisconnect?.error);
239
+ console.log(`WhatsApp disconnected with status code: ${statusCode ?? "unknown"}`);
240
+
241
+ // Clean up socket reference
242
+ socket = null;
243
+
244
+ // Permanent revocation (401): delete credentials and stop
245
+ if (statusCode !== null && PERMANENT_DISCONNECT_CODES.includes(statusCode)) {
246
+ console.log("WhatsApp credentials revoked. Deleting stored credentials.");
247
+ deleteCredentials();
248
+ connectionState = { status: "disconnected", qrCode: null };
249
+ return;
250
+ }
251
+
252
+ // Restart required (515): reconnect immediately with existing credentials.
253
+ // This is expected after initial pairing and during periodic server restarts.
254
+ if (statusCode !== null && RECONNECT_DISCONNECT_CODES.includes(statusCode)) {
255
+ console.log("WhatsApp restart required. Reconnecting with existing credentials...");
256
+ connectionState = { status: "connecting", qrCode: null };
257
+ scheduleReconnect();
258
+ return;
259
+ }
260
+
261
+ // Other disconnects: only auto-reconnect if we previously had a successful connection.
262
+ // During the initial pairing flow (QR scan), Baileys handles its own
263
+ // internal retries. Creating a new socket here would kill the pairing.
264
+ if (hasBeenConnected && !manuallyStopped) {
265
+ connectionState = { status: "connecting", qrCode: null };
266
+ scheduleReconnect();
267
+ } else {
268
+ connectionState = { status: "disconnected", qrCode: null };
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Handle incoming messages from Baileys.
275
+ * Filters messages via shouldHandleMessage and routes valid ones to handleIncomingMessage.
276
+ *
277
+ * @param upsert - The messages.upsert event payload from Baileys
278
+ * @param sock - The active Baileys socket
279
+ */
280
+ function handleMessagesUpsert(
281
+ upsert: { messages: proto.IWebMessageInfo[]; type: string },
282
+ sock: WASocket
283
+ ): void {
284
+ // Only process new messages (not history sync)
285
+ if (upsert.type !== "notify") {
286
+ return;
287
+ }
288
+
289
+ const ownJid = sock.user?.id;
290
+ if (!ownJid) {
291
+ return;
292
+ }
293
+
294
+ for (const rawMsg of upsert.messages) {
295
+ const parsed = shouldHandleMessage(rawMsg, ownJid);
296
+ if (!parsed) {
297
+ continue;
298
+ }
299
+
300
+ handleIncomingMessage(parsed).catch((err: unknown) => {
301
+ console.error(`WhatsApp message handling failed for ${parsed.groupJid}: ${err}`);
302
+ });
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Extract the HTTP status code from a Baileys disconnect error.
308
+ * Baileys disconnect errors carry an output.statusCode property.
309
+ *
310
+ * @param error - The disconnect error
311
+ * @returns The status code, or null if not extractable
312
+ */
313
+ function extractDisconnectStatusCode(error: Error | undefined): number | null {
314
+ if (!error) {
315
+ return null;
316
+ }
317
+
318
+ // Baileys disconnect errors have an output.statusCode property
319
+ const maybeStatusCode = (error as unknown as { output?: { statusCode?: number } }).output?.statusCode;
320
+ if (typeof maybeStatusCode === "number") {
321
+ return maybeStatusCode;
322
+ }
323
+
324
+ return null;
325
+ }
326
+
327
+ /**
328
+ * Schedule a reconnect attempt with exponential backoff.
329
+ * Doubles the delay on each attempt, capped at RECONNECT_MAX_DELAY_MS.
330
+ */
331
+ function scheduleReconnect(): void {
332
+ clearReconnectTimer();
333
+
334
+ const delay = Math.min(
335
+ RECONNECT_BASE_DELAY_MS * Math.pow(2, reconnectAttempt),
336
+ RECONNECT_MAX_DELAY_MS
337
+ );
338
+ reconnectAttempt++;
339
+
340
+ console.log(`WhatsApp reconnecting in ${delay / 1000}s (attempt ${reconnectAttempt})...`);
341
+
342
+ reconnectTimer = setTimeout(async () => {
343
+ if (manuallyStopped) {
344
+ return;
345
+ }
346
+
347
+ try {
348
+ await createSocket();
349
+ } catch (err: unknown) {
350
+ console.error(`WhatsApp reconnect failed: ${err}`);
351
+ if (!manuallyStopped) {
352
+ scheduleReconnect();
353
+ }
354
+ }
355
+ }, delay);
356
+ }
357
+
358
+ /**
359
+ * Clear any pending reconnect timer.
360
+ */
361
+ function clearReconnectTimer(): void {
362
+ if (reconnectTimer) {
363
+ clearTimeout(reconnectTimer);
364
+ reconnectTimer = null;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Close and clean up the active socket.
370
+ */
371
+ function destroySocket(): void {
372
+ if (socket) {
373
+ try {
374
+ socket.end(undefined);
375
+ } catch {
376
+ // Socket may already be closed
377
+ }
378
+ socket = null;
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Delete stored WhatsApp credentials from disk.
384
+ * Used when credentials are permanently revoked (401/515).
385
+ */
386
+ function deleteCredentials(): void {
387
+ if (existsSync(CREDENTIALS_DIR)) {
388
+ try {
389
+ rmSync(CREDENTIALS_DIR, { recursive: true, force: true });
390
+ console.log(`Deleted WhatsApp credentials at ${CREDENTIALS_DIR}`);
391
+ } catch (err: unknown) {
392
+ console.error(`Failed to delete WhatsApp credentials: ${err}`);
393
+ }
394
+ }
395
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Unit tests for WhatsApp message handler functions.
3
+ *
4
+ * Tests shouldHandleMessage filtering and collectSseResponse SSE parsing.
5
+ *
6
+ * Run: node --experimental-test-module-mocks --import tsx/esm --test server/services/whatsapp-message-handler.test.ts
7
+ */
8
+
9
+ import { describe, it, mock, beforeEach } from "node:test";
10
+ import { strict as assert } from "node:assert";
11
+ import { proto } from "baileys";
12
+
13
+ // ============================================================================
14
+ // MODULE MOCKS (must be set up before importing the module under test)
15
+ // ============================================================================
16
+
17
+ /** Tracks which groupJids are considered "mapped" to agents */
18
+ const mappedGroups: Map<string, string> = new Map();
19
+
20
+ mock.module("./whatsapp-manager.js", {
21
+ namedExports: {
22
+ getSocket: () => null,
23
+ },
24
+ });
25
+
26
+ mock.module("./whatsapp-groups.js", {
27
+ namedExports: {
28
+ getAgentIdForGroup: (jid: string) => mappedGroups.get(jid),
29
+ getLastSessionId: () => null,
30
+ setLastSessionId: async () => {},
31
+ },
32
+ });
33
+
34
+ const { shouldHandleMessage, streamSseSegments, normalizeJid } = await import(
35
+ "./whatsapp-message-handler.js"
36
+ );
37
+
38
+ // ============================================================================
39
+ // HELPERS
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Build a minimal Baileys IWebMessageInfo-like object for testing.
44
+ *
45
+ * @param overrides - Fields to override on the default message
46
+ * @returns A fake Baileys message
47
+ */
48
+ function buildMessage(overrides: {
49
+ remoteJid?: string;
50
+ participant?: string;
51
+ fromMe?: boolean;
52
+ text?: string | null;
53
+ id?: string;
54
+ } = {}): proto.IWebMessageInfo {
55
+ const {
56
+ remoteJid = "120363001234567890@g.us",
57
+ participant = "5511999998888@s.whatsapp.net",
58
+ fromMe = false,
59
+ text = "Hello agent",
60
+ id = "msg-001",
61
+ } = overrides;
62
+
63
+ return {
64
+ key: {
65
+ remoteJid,
66
+ participant,
67
+ fromMe,
68
+ id,
69
+ },
70
+ message: text !== null
71
+ ? { conversation: text }
72
+ : undefined,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Create a fake Response with an SSE body stream.
78
+ *
79
+ * @param sseEvents - Array of SSE event strings (each is a "data: {...}" line)
80
+ * @param status - HTTP status code
81
+ * @returns A Response-like object
82
+ */
83
+ function buildSseResponse(sseEvents: string[], status = 200): Response {
84
+ const sseText = sseEvents.map((e) => `data: ${e}\n\n`).join("");
85
+ const encoder = new TextEncoder();
86
+ const encoded = encoder.encode(sseText);
87
+
88
+ const stream = new ReadableStream({
89
+ start(controller) {
90
+ controller.enqueue(encoded);
91
+ controller.close();
92
+ },
93
+ });
94
+
95
+ return new Response(stream, {
96
+ status,
97
+ headers: { "Content-Type": "text/event-stream" },
98
+ });
99
+ }
100
+
101
+ // ============================================================================
102
+ // TESTS: shouldHandleMessage
103
+ // ============================================================================
104
+
105
+ describe("shouldHandleMessage", () => {
106
+ const ownJid = "5511999991111@s.whatsapp.net";
107
+ const mappedGroupJid = "120363001234567890@g.us";
108
+
109
+ beforeEach(() => {
110
+ mappedGroups.clear();
111
+ mappedGroups.set(mappedGroupJid, "agent-1");
112
+ });
113
+
114
+ it("returns null for DMs (user JID, not a group)", () => {
115
+ const msg = buildMessage({ remoteJid: "5511999998888@s.whatsapp.net" });
116
+ const result = shouldHandleMessage(msg, ownJid);
117
+ assert.equal(result, null);
118
+ });
119
+
120
+ it("returns null for status broadcasts", () => {
121
+ const msg = buildMessage({ remoteJid: "status@broadcast" });
122
+ const result = shouldHandleMessage(msg, ownJid);
123
+ assert.equal(result, null);
124
+ });
125
+
126
+ it("returns null for messages from the bot itself (fromMe: true)", () => {
127
+ const msg = buildMessage({
128
+ remoteJid: mappedGroupJid,
129
+ fromMe: true,
130
+ participant: ownJid,
131
+ });
132
+ const result = shouldHandleMessage(msg, ownJid);
133
+ assert.equal(result, null);
134
+ });
135
+
136
+ it("returns null for messages in unmapped groups", () => {
137
+ const unmappedGroupJid = "120363009999999999@g.us";
138
+ const msg = buildMessage({ remoteJid: unmappedGroupJid });
139
+ const result = shouldHandleMessage(msg, ownJid);
140
+ assert.equal(result, null);
141
+ });
142
+
143
+ it("returns null for non-text messages (no message content)", () => {
144
+ const msg = buildMessage({ remoteJid: mappedGroupJid, text: null });
145
+ const result = shouldHandleMessage(msg, ownJid);
146
+ assert.equal(result, null);
147
+ });
148
+
149
+ it("returns null when remoteJid is missing", () => {
150
+ const msg = {
151
+ key: { remoteJid: undefined, participant: "someone@s.whatsapp.net", fromMe: false },
152
+ message: { conversation: "Hello" },
153
+ };
154
+ const result = shouldHandleMessage(msg, ownJid);
155
+ assert.equal(result, null);
156
+ });
157
+
158
+ it("returns a WhatsAppIncomingMessage for valid text in a mapped group", () => {
159
+ const msg = buildMessage({
160
+ remoteJid: mappedGroupJid,
161
+ participant: "5511999998888@s.whatsapp.net",
162
+ text: "What is the weather?",
163
+ });
164
+
165
+ const result = shouldHandleMessage(msg, ownJid);
166
+ assert.notEqual(result, null);
167
+ assert.equal(result!.groupJid, mappedGroupJid);
168
+ assert.equal(result!.senderJid, "5511999998888@s.whatsapp.net");
169
+ assert.equal(result!.text, "What is the weather?");
170
+ assert.equal(result!.messageId, "msg-001");
171
+ });
172
+
173
+ it("handles JID normalization -- ownJid with :0 device suffix", () => {
174
+ const ownJidWithDevice = "5511999991111:0@s.whatsapp.net";
175
+ // Sender JID matches ownJid after stripping the :0 suffix, but fromMe=false
176
+ // so it should NOT be filtered as "bot itself". However, the sender and owner
177
+ // have different numbers, so this message passes through.
178
+ const msg = buildMessage({
179
+ remoteJid: mappedGroupJid,
180
+ participant: "5511999998888@s.whatsapp.net",
181
+ });
182
+
183
+ const result = shouldHandleMessage(msg, ownJidWithDevice);
184
+ assert.notEqual(result, null);
185
+ assert.equal(result!.groupJid, mappedGroupJid);
186
+ });
187
+
188
+ it("normalizeJid strips :0 device suffix correctly", () => {
189
+ assert.equal(normalizeJid("5511999991111:0@s.whatsapp.net"), "5511999991111@s.whatsapp.net");
190
+ assert.equal(normalizeJid("5511999991111:42@s.whatsapp.net"), "5511999991111@s.whatsapp.net");
191
+ assert.equal(normalizeJid("5511999991111@s.whatsapp.net"), "5511999991111@s.whatsapp.net");
192
+ });
193
+
194
+ it("filters out messages where normalized sender matches normalized ownJid", () => {
195
+ // Sender has :0 suffix, ownJid does not, but after normalization they match
196
+ const msg = buildMessage({
197
+ remoteJid: mappedGroupJid,
198
+ participant: "5511999991111:0@s.whatsapp.net",
199
+ fromMe: false,
200
+ });
201
+
202
+ const result = shouldHandleMessage(msg, ownJid);
203
+ assert.equal(result, null);
204
+ });
205
+ });
206
+
207
+ // ============================================================================
208
+ // HELPERS: collect all segments from an async generator
209
+ // ============================================================================
210
+
211
+ import type { SseSegment } from "./whatsapp-message-handler.js";
212
+
213
+ /**
214
+ * Collect all segments from the streamSseSegments async generator into an array.
215
+ *
216
+ * @param gen - The async generator to drain
217
+ * @returns Array of all yielded SseSegment objects
218
+ */
219
+ async function collectSegments(gen: AsyncGenerator<SseSegment>): Promise<SseSegment[]> {
220
+ const segments: SseSegment[] = [];
221
+ for await (const segment of gen) {
222
+ segments.push(segment);
223
+ }
224
+ return segments;
225
+ }
226
+
227
+ // ============================================================================
228
+ // TESTS: streamSseSegments
229
+ // ============================================================================
230
+
231
+ describe("streamSseSegments", () => {
232
+ it("yields separate segments at tool_start boundaries", async () => {
233
+ const events = [
234
+ JSON.stringify({ type: "text_delta", content: "Hello" }),
235
+ JSON.stringify({ type: "tool_start", toolName: "read_file" }),
236
+ JSON.stringify({ type: "text_delta", content: "Done" }),
237
+ JSON.stringify({ type: "result", sessionId: "sess-abc-123" }),
238
+ ];
239
+
240
+ const response = buildSseResponse(events);
241
+ const segments = await collectSegments(streamSseSegments(response));
242
+
243
+ assert.equal(segments.length, 2);
244
+ assert.equal(segments[0]!.text, "Hello");
245
+ assert.equal(segments[0]!.sessionId, null);
246
+ assert.equal(segments[1]!.text, "Done");
247
+ assert.equal(segments[1]!.sessionId, "sess-abc-123");
248
+ });
249
+
250
+ it("yields single segment when no tool calls", async () => {
251
+ const events = [
252
+ JSON.stringify({ type: "text_delta", content: "Hello " }),
253
+ JSON.stringify({ type: "text_delta", content: "world!" }),
254
+ JSON.stringify({ type: "result", sessionId: "sess-abc-123" }),
255
+ ];
256
+
257
+ const response = buildSseResponse(events);
258
+ const segments = await collectSegments(streamSseSegments(response));
259
+
260
+ assert.equal(segments.length, 1);
261
+ assert.equal(segments[0]!.text, "Hello world!");
262
+ assert.equal(segments[0]!.sessionId, "sess-abc-123");
263
+ });
264
+
265
+ it("yields isAlreadyStreaming segment for HTTP 409", async () => {
266
+ const response = new Response("Already streaming", { status: 409 });
267
+ const segments = await collectSegments(streamSseSegments(response));
268
+
269
+ assert.equal(segments.length, 1);
270
+ assert.equal(segments[0]!.isAlreadyStreaming, true);
271
+ });
272
+ });