@cortexmemory/cli 0.27.3 → 0.27.4

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,454 @@
1
+ /**
2
+ * E2E Tests: Chat API Memory Flow
3
+ *
4
+ * These tests verify the FULL memory flow through the chat API routes:
5
+ * - Fact storage from conversations
6
+ * - Belief revision (superseding facts)
7
+ * - Memory recall across conversations
8
+ * - Conversation lifecycle (create, chat, delete)
9
+ *
10
+ * REQUIRES:
11
+ * - CONVEX_URL: Real Convex deployment
12
+ * - OPENAI_API_KEY: For LLM calls and embeddings
13
+ * - CORTEX_FACT_EXTRACTION=true: Enable fact extraction
14
+ *
15
+ * These tests use real HTTP requests to the quickstart server,
16
+ * so the server must be running on localhost:3000.
17
+ */
18
+
19
+ import { Cortex } from "@cortexmemory/sdk";
20
+
21
+ // Skip if required env vars not set
22
+ const SKIP_E2E =
23
+ !process.env.CONVEX_URL ||
24
+ !process.env.OPENAI_API_KEY ||
25
+ !process.env.QUICKSTART_URL;
26
+
27
+ const BASE_URL = process.env.QUICKSTART_URL || "http://localhost:3000";
28
+
29
+ // Generate unique IDs for test isolation
30
+ function generateTestId(prefix: string): string {
31
+ const timestamp = Date.now();
32
+ const random = Math.random().toString(36).slice(2, 8);
33
+ return `${prefix}-e2e-${timestamp}-${random}`;
34
+ }
35
+
36
+ /**
37
+ * Make a chat request to the API
38
+ */
39
+ async function sendChatMessage(
40
+ endpoint: string,
41
+ messages: Array<{ role: string; content: string }>,
42
+ options: {
43
+ userId: string;
44
+ memorySpaceId: string;
45
+ conversationId?: string;
46
+ }
47
+ ): Promise<{
48
+ response: string;
49
+ conversationId?: string;
50
+ }> {
51
+ const response = await fetch(`${BASE_URL}/api/${endpoint}`, {
52
+ method: "POST",
53
+ headers: { "Content-Type": "application/json" },
54
+ body: JSON.stringify({
55
+ messages: messages.map((m, i) => ({
56
+ id: `msg-${i}`,
57
+ role: m.role,
58
+ content: m.content,
59
+ createdAt: new Date().toISOString(),
60
+ })),
61
+ userId: options.userId,
62
+ memorySpaceId: options.memorySpaceId,
63
+ conversationId: options.conversationId,
64
+ }),
65
+ });
66
+
67
+ if (!response.ok) {
68
+ const error = await response.text();
69
+ throw new Error(`Chat API error: ${response.status} - ${error}`);
70
+ }
71
+
72
+ // Parse streaming response
73
+ const text = await response.text();
74
+
75
+ // Extract text content from the stream (simplified parsing)
76
+ let fullResponse = "";
77
+ let conversationId: string | undefined;
78
+
79
+ const lines = text.split("\n");
80
+ for (const line of lines) {
81
+ if (line.startsWith("0:")) {
82
+ // Text content
83
+ try {
84
+ const content = JSON.parse(line.slice(2));
85
+ if (typeof content === "string") {
86
+ fullResponse += content;
87
+ }
88
+ } catch {
89
+ // Ignore parse errors
90
+ }
91
+ } else if (line.includes("data-conversation-update")) {
92
+ // Extract conversation ID
93
+ try {
94
+ const match = line.match(/"conversationId":"([^"]+)"/);
95
+ if (match) {
96
+ conversationId = match[1];
97
+ }
98
+ } catch {
99
+ // Ignore parse errors
100
+ }
101
+ }
102
+ }
103
+
104
+ return { response: fullResponse, conversationId };
105
+ }
106
+
107
+ describe("Chat Memory Flow E2E", () => {
108
+ let cortex: Cortex;
109
+ let testUserId: string;
110
+ let testMemorySpaceId: string;
111
+
112
+ beforeAll(() => {
113
+ if (SKIP_E2E) {
114
+ console.log(
115
+ "Skipping E2E tests - CONVEX_URL, OPENAI_API_KEY, or QUICKSTART_URL not configured"
116
+ );
117
+ return;
118
+ }
119
+ cortex = new Cortex({ convexUrl: process.env.CONVEX_URL! });
120
+ });
121
+
122
+ beforeEach(() => {
123
+ if (SKIP_E2E) return;
124
+ testUserId = generateTestId("user");
125
+ testMemorySpaceId = generateTestId("space");
126
+ });
127
+
128
+ afterAll(async () => {
129
+ if (cortex) {
130
+ cortex.close();
131
+ }
132
+ });
133
+
134
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
135
+ // V5 Route Tests
136
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
137
+
138
+ (SKIP_E2E ? describe.skip : describe)("v5 route (/api/chat)", () => {
139
+ it("should store facts from conversation", async () => {
140
+ // Send a message with a fact
141
+ await sendChatMessage(
142
+ "chat",
143
+ [{ role: "user", content: "My name is Alice and I work as a software engineer" }],
144
+ { userId: testUserId, memorySpaceId: testMemorySpaceId }
145
+ );
146
+
147
+ // Wait for fact extraction
148
+ await new Promise((r) => setTimeout(r, 5000));
149
+
150
+ // Verify facts were stored
151
+ const facts = await cortex.facts.list({
152
+ memorySpaceId: testMemorySpaceId,
153
+ userId: testUserId,
154
+ includeSuperseded: false,
155
+ });
156
+
157
+ console.log(`[V5] Stored facts: ${facts.length}`);
158
+ facts.forEach((f) => console.log(` - ${f.fact}`));
159
+
160
+ expect(facts.length).toBeGreaterThan(0);
161
+ }, 60000);
162
+
163
+ it("should supersede facts through belief revision", async () => {
164
+ // First message: establish a preference
165
+ await sendChatMessage(
166
+ "chat",
167
+ [{ role: "user", content: "My favorite color is blue" }],
168
+ { userId: testUserId, memorySpaceId: testMemorySpaceId }
169
+ );
170
+
171
+ await new Promise((r) => setTimeout(r, 5000));
172
+
173
+ // Second message: change the preference
174
+ await sendChatMessage(
175
+ "chat",
176
+ [
177
+ { role: "user", content: "My favorite color is blue" },
178
+ { role: "assistant", content: "Got it, blue is your favorite color!" },
179
+ { role: "user", content: "Actually, my favorite color is purple now" },
180
+ ],
181
+ { userId: testUserId, memorySpaceId: testMemorySpaceId }
182
+ );
183
+
184
+ await new Promise((r) => setTimeout(r, 5000));
185
+
186
+ // Check facts
187
+ const allFacts = await cortex.facts.list({
188
+ memorySpaceId: testMemorySpaceId,
189
+ userId: testUserId,
190
+ includeSuperseded: true,
191
+ });
192
+
193
+ const activeFacts = await cortex.facts.list({
194
+ memorySpaceId: testMemorySpaceId,
195
+ userId: testUserId,
196
+ includeSuperseded: false,
197
+ });
198
+
199
+ console.log(`[V5] All facts: ${allFacts.length}, Active: ${activeFacts.length}`);
200
+ allFacts.forEach((f) => {
201
+ const status = f.supersededBy ? "SUPERSEDED" : "ACTIVE";
202
+ console.log(` [${status}] ${f.fact}`);
203
+ });
204
+
205
+ // Should have superseded the old color preference
206
+ const colorFacts = activeFacts.filter(
207
+ (f) =>
208
+ f.fact.toLowerCase().includes("color") ||
209
+ f.fact.toLowerCase().includes("purple") ||
210
+ f.fact.toLowerCase().includes("blue")
211
+ );
212
+
213
+ // Ideally only one active color fact (purple)
214
+ expect(colorFacts.length).toBeLessThanOrEqual(2);
215
+ }, 90000);
216
+
217
+ it("should recall facts in subsequent conversations", async () => {
218
+ // First conversation: store a fact
219
+ const conv1Result = await sendChatMessage(
220
+ "chat",
221
+ [{ role: "user", content: "I have a dog named Max" }],
222
+ { userId: testUserId, memorySpaceId: testMemorySpaceId }
223
+ );
224
+
225
+ await new Promise((r) => setTimeout(r, 5000));
226
+
227
+ // Second conversation: ask about the fact
228
+ const conv2Result = await sendChatMessage(
229
+ "chat",
230
+ [{ role: "user", content: "What do you remember about my pets?" }],
231
+ { userId: testUserId, memorySpaceId: testMemorySpaceId }
232
+ );
233
+
234
+ console.log(`[V5] Recall response: ${conv2Result.response.slice(0, 200)}...`);
235
+
236
+ // Response should mention Max (the dog)
237
+ const responseText = conv2Result.response.toLowerCase();
238
+ const mentionsPet = responseText.includes("max") || responseText.includes("dog");
239
+
240
+ // Note: LLM responses are non-deterministic, so we just verify we got a response
241
+ expect(conv2Result.response.length).toBeGreaterThan(0);
242
+ }, 90000);
243
+ });
244
+
245
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
246
+ // V6 Route Tests
247
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
248
+
249
+ (SKIP_E2E ? describe.skip : describe)("v6 route (/api/chat-v6)", () => {
250
+ it("should store facts from conversation", async () => {
251
+ // Send a message with a fact
252
+ await sendChatMessage(
253
+ "chat-v6",
254
+ [{ role: "user", content: "My name is Bob and I'm a data scientist" }],
255
+ { userId: testUserId, memorySpaceId: testMemorySpaceId }
256
+ );
257
+
258
+ // Wait for fact extraction
259
+ await new Promise((r) => setTimeout(r, 5000));
260
+
261
+ // Verify facts were stored
262
+ const facts = await cortex.facts.list({
263
+ memorySpaceId: testMemorySpaceId,
264
+ userId: testUserId,
265
+ includeSuperseded: false,
266
+ });
267
+
268
+ console.log(`[V6] Stored facts: ${facts.length}`);
269
+ facts.forEach((f) => console.log(` - ${f.fact}`));
270
+
271
+ expect(facts.length).toBeGreaterThan(0);
272
+ }, 60000);
273
+
274
+ it("should supersede facts through belief revision", async () => {
275
+ // First message: establish a preference
276
+ await sendChatMessage(
277
+ "chat-v6",
278
+ [{ role: "user", content: "I prefer tea over coffee" }],
279
+ { userId: testUserId, memorySpaceId: testMemorySpaceId }
280
+ );
281
+
282
+ await new Promise((r) => setTimeout(r, 5000));
283
+
284
+ // Second message: change the preference
285
+ await sendChatMessage(
286
+ "chat-v6",
287
+ [
288
+ { role: "user", content: "I prefer tea over coffee" },
289
+ { role: "assistant", content: "Got it, you prefer tea!" },
290
+ { role: "user", content: "Actually I've switched to coffee now, it helps me focus" },
291
+ ],
292
+ { userId: testUserId, memorySpaceId: testMemorySpaceId }
293
+ );
294
+
295
+ await new Promise((r) => setTimeout(r, 5000));
296
+
297
+ // Check facts
298
+ const allFacts = await cortex.facts.list({
299
+ memorySpaceId: testMemorySpaceId,
300
+ userId: testUserId,
301
+ includeSuperseded: true,
302
+ });
303
+
304
+ const activeFacts = await cortex.facts.list({
305
+ memorySpaceId: testMemorySpaceId,
306
+ userId: testUserId,
307
+ includeSuperseded: false,
308
+ });
309
+
310
+ console.log(`[V6] All facts: ${allFacts.length}, Active: ${activeFacts.length}`);
311
+ allFacts.forEach((f) => {
312
+ const status = f.supersededBy ? "SUPERSEDED" : "ACTIVE";
313
+ console.log(` [${status}] ${f.fact}`);
314
+ });
315
+
316
+ // Should have at least one fact about beverages
317
+ const beverageFacts = allFacts.filter(
318
+ (f) =>
319
+ f.fact.toLowerCase().includes("tea") ||
320
+ f.fact.toLowerCase().includes("coffee")
321
+ );
322
+ expect(beverageFacts.length).toBeGreaterThan(0);
323
+ }, 90000);
324
+
325
+ it("should recall facts in subsequent conversations", async () => {
326
+ // First conversation: store a fact
327
+ await sendChatMessage(
328
+ "chat-v6",
329
+ [{ role: "user", content: "I live in San Francisco" }],
330
+ { userId: testUserId, memorySpaceId: testMemorySpaceId }
331
+ );
332
+
333
+ await new Promise((r) => setTimeout(r, 5000));
334
+
335
+ // Second conversation: ask about the fact
336
+ const conv2Result = await sendChatMessage(
337
+ "chat-v6",
338
+ [{ role: "user", content: "Where do I live?" }],
339
+ { userId: testUserId, memorySpaceId: testMemorySpaceId }
340
+ );
341
+
342
+ console.log(`[V6] Recall response: ${conv2Result.response.slice(0, 200)}...`);
343
+
344
+ // Verify we got a response
345
+ expect(conv2Result.response.length).toBeGreaterThan(0);
346
+ }, 90000);
347
+ });
348
+
349
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
350
+ // Feature Parity Tests
351
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
352
+
353
+ (SKIP_E2E ? describe.skip : describe)("v5 vs v6 feature parity", () => {
354
+ it("both routes should store facts for the same message", async () => {
355
+ const v5UserId = generateTestId("user-v5");
356
+ const v6UserId = generateTestId("user-v6");
357
+ const sharedSpaceId = testMemorySpaceId;
358
+
359
+ // Send same message to both routes
360
+ const message = "I am a TypeScript developer with 5 years of experience";
361
+
362
+ await Promise.all([
363
+ sendChatMessage(
364
+ "chat",
365
+ [{ role: "user", content: message }],
366
+ { userId: v5UserId, memorySpaceId: sharedSpaceId }
367
+ ),
368
+ sendChatMessage(
369
+ "chat-v6",
370
+ [{ role: "user", content: message }],
371
+ { userId: v6UserId, memorySpaceId: sharedSpaceId }
372
+ ),
373
+ ]);
374
+
375
+ // Wait for fact extraction
376
+ await new Promise((r) => setTimeout(r, 7000));
377
+
378
+ // Check facts for both users
379
+ const [v5Facts, v6Facts] = await Promise.all([
380
+ cortex.facts.list({
381
+ memorySpaceId: sharedSpaceId,
382
+ userId: v5UserId,
383
+ includeSuperseded: false,
384
+ }),
385
+ cortex.facts.list({
386
+ memorySpaceId: sharedSpaceId,
387
+ userId: v6UserId,
388
+ includeSuperseded: false,
389
+ }),
390
+ ]);
391
+
392
+ console.log(`V5 facts: ${v5Facts.length}, V6 facts: ${v6Facts.length}`);
393
+ console.log("V5 facts:", v5Facts.map((f) => f.fact));
394
+ console.log("V6 facts:", v6Facts.map((f) => f.fact));
395
+
396
+ // CRITICAL: Both routes should store facts
397
+ expect(v5Facts.length).toBeGreaterThan(0);
398
+ expect(v6Facts.length).toBeGreaterThan(0);
399
+
400
+ // Fact counts should be similar (allow some variance due to LLM non-determinism)
401
+ const diff = Math.abs(v5Facts.length - v6Facts.length);
402
+ expect(diff).toBeLessThanOrEqual(2);
403
+ }, 90000);
404
+ });
405
+
406
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
407
+ // Conversation Lifecycle
408
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
409
+
410
+ (SKIP_E2E ? describe.skip : describe)("conversation lifecycle", () => {
411
+ it("should create, list, and delete conversations", async () => {
412
+ // Create a conversation via chat
413
+ const chatResult = await sendChatMessage(
414
+ "chat",
415
+ [{ role: "user", content: "Hello, this is a test conversation" }],
416
+ { userId: testUserId, memorySpaceId: testMemorySpaceId }
417
+ );
418
+
419
+ // Wait for conversation to be created
420
+ await new Promise((r) => setTimeout(r, 2000));
421
+
422
+ // List conversations
423
+ const listResponse = await fetch(
424
+ `${BASE_URL}/api/conversations?userId=${testUserId}&memorySpaceId=${testMemorySpaceId}`
425
+ );
426
+ const listData = await listResponse.json();
427
+
428
+ console.log(`Conversations: ${JSON.stringify(listData.conversations, null, 2)}`);
429
+ expect(listData.conversations).toBeDefined();
430
+ expect(listData.conversations.length).toBeGreaterThan(0);
431
+
432
+ // Delete conversation
433
+ const convId = listData.conversations[0].id;
434
+ const deleteResponse = await fetch(
435
+ `${BASE_URL}/api/conversations?conversationId=${convId}`,
436
+ { method: "DELETE" }
437
+ );
438
+ const deleteData = await deleteResponse.json();
439
+
440
+ expect(deleteData.success).toBe(true);
441
+
442
+ // Verify deletion
443
+ const listAfterDelete = await fetch(
444
+ `${BASE_URL}/api/conversations?userId=${testUserId}&memorySpaceId=${testMemorySpaceId}`
445
+ );
446
+ const listAfterDeleteData = await listAfterDelete.json();
447
+
448
+ // Should have one less conversation
449
+ expect(listAfterDeleteData.conversations.length).toBeLessThan(
450
+ listData.conversations.length
451
+ );
452
+ }, 60000);
453
+ });
454
+ });