@gethmy/mcp 2.4.7 → 2.5.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.
@@ -1,483 +0,0 @@
1
- /**
2
- * Unit tests for the Active Learning system.
3
- *
4
- * These tests use a mock API client to verify extraction rules
5
- * and confidence-based routing - no real API calls.
6
- *
7
- * Run with: bun test packages/mcp-server/src/__tests__/active-learning.test.ts
8
- */
9
-
10
- import { describe, expect, mock, test } from "bun:test";
11
-
12
- // Mock the config module before importing active-learning
13
- let mockWorkspaceId: string | null = "ws-test-123";
14
- const mockProjectId: string | null = "proj-test-456";
15
-
16
- mock.module("../config.js", () => ({
17
- getActiveWorkspaceId: () => mockWorkspaceId,
18
- getActiveProjectId: () => mockProjectId,
19
- isConfigured: () => true,
20
- loadConfig: () => {},
21
- }));
22
-
23
- // Import after mocking
24
- const { extractLearnings } = await import("../active-learning.js");
25
- type SessionContext = import("../active-learning.js").SessionContext;
26
-
27
- // Extended mock client that satisfies all methods called by fire-and-forget paths
28
- // (autoExpandGraph, detectAndCreatePatterns). Unused by most tests but prevents crashes.
29
- function makeFullMockClient() {
30
- const createdEntities: Array<Record<string, unknown>> = [];
31
- let nextId = 1;
32
-
33
- return {
34
- createdEntities,
35
- createMemoryEntity: mock(async (data: Record<string, unknown>) => {
36
- const entity = { id: `entity-${nextId++}`, ...data };
37
- createdEntities.push(entity);
38
- return { entity };
39
- }),
40
- updateMemoryEntity: mock(
41
- async (entityId: string, updates: Record<string, unknown>) => ({
42
- entity: { id: entityId, ...updates },
43
- }),
44
- ),
45
- deleteMemoryEntity: mock(async () => ({ success: true })),
46
- listMemoryEntities: mock(async () => ({
47
- entities: [] as unknown[],
48
- count: 0,
49
- })),
50
- getMemoryEntity: mock(async (id: string) => ({
51
- entity: { id, type: "lesson" },
52
- })),
53
- searchMemoryEntities: mock(async () => ({
54
- entities: [] as unknown[],
55
- count: 0,
56
- })),
57
- createMemoryRelation: mock(async () => ({ relation: {} })),
58
- };
59
- }
60
-
61
- // ---- Mock helpers ----
62
-
63
- function makeMockClient() {
64
- const createdEntities: Array<Record<string, unknown>> = [];
65
- let nextId = 1;
66
-
67
- return {
68
- createdEntities,
69
- createMemoryEntity: mock(async (data: Record<string, unknown>) => {
70
- const entity = { id: `entity-${nextId++}`, ...data };
71
- createdEntities.push(entity);
72
- return { entity };
73
- }),
74
- updateMemoryEntity: mock(
75
- async (entityId: string, updates: Record<string, unknown>) => {
76
- return { entity: { id: entityId, ...updates } };
77
- },
78
- ),
79
- deleteMemoryEntity: mock(async () => ({ success: true })),
80
- listMemoryEntities: mock(async () => ({
81
- entities: [] as unknown[],
82
- count: 0,
83
- })),
84
- };
85
- }
86
-
87
- function makeSession(overrides: Partial<SessionContext> = {}): SessionContext {
88
- return {
89
- cardId: "card-1",
90
- cardTitle: "Fix login bug",
91
- cardLabels: ["feature"],
92
- agentIdentifier: "claude-code",
93
- agentName: "Claude Code",
94
- status: "completed",
95
- progressPercent: 100,
96
- ...overrides,
97
- };
98
- }
99
-
100
- // ---- Tests ----
101
-
102
- describe("extractLearnings", () => {
103
- describe("Rule 1: Blockers create error entities", () => {
104
- test("session with blockers creates error entities", async () => {
105
- const client = makeMockClient();
106
- const session = makeSession({
107
- blockers: ["API rate limit exceeded", "Database timeout"],
108
- status: "completed",
109
- });
110
-
111
- const result = await extractLearnings(client as never, session);
112
-
113
- // 2 blockers + 1 session lesson = 3 entities
114
- expect(result.count).toBe(3);
115
- expect(result.entityIds).toHaveLength(3);
116
-
117
- // Check error entities were created for each blocker
118
- const errorEntities = client.createdEntities.filter(
119
- (e) => e.type === "error",
120
- );
121
- expect(errorEntities).toHaveLength(2);
122
- expect(errorEntities[0].title).toContain("Blocker:");
123
- expect(errorEntities[0].title).toContain("API rate limit");
124
- expect(errorEntities[0].memory_tier).toBe("reference");
125
- expect(errorEntities[0].confidence).toBe(0.7);
126
- expect((errorEntities[0].tags as string[]) ?? []).toContain(
127
- "auto-extracted",
128
- );
129
- expect((errorEntities[0].tags as string[]) ?? []).toContain("blocker");
130
- });
131
-
132
- test("blocker title is truncated to 100 chars", async () => {
133
- const client = makeMockClient();
134
- const longBlocker = "A".repeat(200);
135
- const session = makeSession({
136
- blockers: [longBlocker],
137
- status: "paused",
138
- });
139
-
140
- await extractLearnings(client as never, session);
141
-
142
- const errorEntities = client.createdEntities.filter(
143
- (e) => e.type === "error",
144
- );
145
- expect(errorEntities).toHaveLength(1);
146
- // Title: "Blocker: " + 100 chars
147
- expect((errorEntities[0].title as string).length).toBeLessThanOrEqual(
148
- 110,
149
- );
150
- });
151
- });
152
-
153
- describe("Rule 2: Completed sessions create lesson entities", () => {
154
- test("clean completed session does NOT create lesson entity", async () => {
155
- const client = makeMockClient();
156
- const session = makeSession({
157
- status: "completed",
158
- currentTask: "Deployed to production",
159
- progressPercent: 100,
160
- sessionDurationMs: 300000, // 5 minutes
161
- });
162
-
163
- const result = await extractLearnings(client as never, session);
164
-
165
- expect(result.count).toBe(0);
166
- const lessons = client.createdEntities.filter((e) => e.type === "lesson");
167
- expect(lessons).toHaveLength(0);
168
- });
169
-
170
- test("completed session with blockers creates lesson entity", async () => {
171
- const client = makeMockClient();
172
- const session = makeSession({
173
- status: "completed",
174
- currentTask: "Deployed to production",
175
- progressPercent: 100,
176
- sessionDurationMs: 300000, // 5 minutes
177
- blockers: ["API rate limit hit"],
178
- });
179
-
180
- const _result = await extractLearnings(client as never, session);
181
-
182
- // 1 error entity (Rule 1) + 1 lesson entity (Rule 2)
183
- const lesson = client.createdEntities.find((e) => e.type === "lesson");
184
- expect(lesson).toBeTruthy();
185
- expect(lesson!.title).toContain("Session:");
186
- expect(lesson!.memory_tier).toBe("episode");
187
- expect(lesson!.confidence).toBe(0.7);
188
- expect(lesson!.content as string).toContain("Deployed to production");
189
- expect(lesson!.content as string).toContain("5 minutes");
190
- });
191
-
192
- test("completed session with incomplete subtasks creates lesson entity", async () => {
193
- const client = makeMockClient();
194
- const session = makeSession({
195
- status: "completed",
196
- currentTask: "Finished main work",
197
- progressPercent: 100,
198
- cardSubtasks: [
199
- { title: "Step 1", done: true },
200
- { title: "Step 2", done: false },
201
- ],
202
- });
203
-
204
- const _result = await extractLearnings(client as never, session);
205
-
206
- const lesson = client.createdEntities.find((e) => e.type === "lesson");
207
- expect(lesson).toBeTruthy();
208
- expect(lesson!.title).toContain("Session:");
209
- });
210
-
211
- test("paused session does NOT create lesson entity", async () => {
212
- const client = makeMockClient();
213
- const session = makeSession({ status: "paused", blockers: undefined });
214
-
215
- const result = await extractLearnings(client as never, session);
216
-
217
- expect(result.count).toBe(0);
218
- const lessons = client.createdEntities.filter((e) => e.type === "lesson");
219
- expect(lessons).toHaveLength(0);
220
- });
221
- });
222
-
223
- describe("Rule 3: Bug label + completed creates solution entity", () => {
224
- test('card with "bug" label creates solution entity', async () => {
225
- const client = makeMockClient();
226
- const session = makeSession({
227
- cardLabels: ["bug", "priority-high"],
228
- status: "completed",
229
- });
230
-
231
- const _result = await extractLearnings(client as never, session);
232
-
233
- const solutions = client.createdEntities.filter(
234
- (e) => e.type === "solution",
235
- );
236
- expect(solutions).toHaveLength(1);
237
- expect(solutions[0].title).toContain("Solution:");
238
- expect(solutions[0].memory_tier).toBe("reference");
239
- expect(solutions[0].confidence).toBe(0.8);
240
- expect((solutions[0].tags as string[]) ?? []).toContain("bug-fix");
241
- });
242
-
243
- test('case-insensitive "Bug" label works', async () => {
244
- const client = makeMockClient();
245
- const session = makeSession({
246
- cardLabels: ["Bug"],
247
- status: "completed",
248
- });
249
-
250
- await extractLearnings(client as never, session);
251
-
252
- const solutions = client.createdEntities.filter(
253
- (e) => e.type === "solution",
254
- );
255
- // The check is .toLowerCase(), so "Bug" matches
256
- expect(solutions).toHaveLength(1);
257
- });
258
-
259
- test("bug label + paused does NOT create solution", async () => {
260
- const client = makeMockClient();
261
- const session = makeSession({
262
- cardLabels: ["bug"],
263
- status: "paused",
264
- });
265
-
266
- await extractLearnings(client as never, session);
267
-
268
- const solutions = client.createdEntities.filter(
269
- (e) => e.type === "solution",
270
- );
271
- expect(solutions).toHaveLength(0);
272
- });
273
-
274
- for (const label of ["fix", "hotfix", "defect", "error"]) {
275
- test(`"${label}" label also triggers solution creation`, async () => {
276
- const client = makeMockClient();
277
- const session = makeSession({
278
- cardLabels: [label],
279
- status: "completed",
280
- });
281
-
282
- await extractLearnings(client as never, session);
283
-
284
- const solutions = client.createdEntities.filter(
285
- (e) => e.type === "solution",
286
- );
287
- expect(solutions).toHaveLength(1);
288
- });
289
- }
290
- });
291
-
292
- describe("Card labels are included in tags", () => {
293
- test("first 3 card labels are appended to tags", async () => {
294
- const client = makeMockClient();
295
- const session = makeSession({
296
- cardLabels: ["feature", "frontend", "urgent", "extra"],
297
- status: "completed",
298
- blockers: ["test blocker"],
299
- });
300
-
301
- await extractLearnings(client as never, session);
302
-
303
- const lesson = client.createdEntities.find((e) => e.type === "lesson");
304
- const tags = lesson!.tags as string[];
305
- expect(tags).toContain("feature");
306
- expect(tags).toContain("frontend");
307
- expect(tags).toContain("urgent");
308
- expect(tags).not.toContain("extra"); // Only first 3
309
- });
310
- });
311
-
312
- describe("Error handling", () => {
313
- test("individual entity creation failure does not break others", async () => {
314
- let callCount = 0;
315
- const client = {
316
- createMemoryEntity: mock(async (_data: Record<string, unknown>) => {
317
- callCount++;
318
- if (callCount === 1) throw new Error("Network error");
319
- return { entity: { id: `entity-${callCount}` } };
320
- }),
321
- };
322
-
323
- const session = makeSession({
324
- blockers: ["blocker-1"],
325
- status: "completed",
326
- });
327
-
328
- // blocker creates error (fails), completed creates lesson (succeeds)
329
- const result = await extractLearnings(client as never, session);
330
-
331
- // Only 1 succeeded out of 2
332
- expect(result.entityIds).toHaveLength(1);
333
- expect(result.count).toBe(1);
334
- });
335
- });
336
-
337
- describe("No workspace returns empty", () => {
338
- test("returns empty when no workspace configured", async () => {
339
- // Temporarily override
340
- const origWs = mockWorkspaceId;
341
- mockWorkspaceId = null;
342
-
343
- const client = makeMockClient();
344
- const session = makeSession();
345
-
346
- const result = await extractLearnings(client as never, session);
347
- expect(result.count).toBe(0);
348
- expect(result.entityIds).toHaveLength(0);
349
-
350
- // Restore
351
- mockWorkspaceId = origWs;
352
- });
353
- });
354
- });
355
-
356
- describe("extractLearnings - entity metadata", () => {
357
- test("stores card_id and source in metadata", async () => {
358
- const client = makeMockClient();
359
- const session = makeSession({
360
- cardId: "card-abc",
361
- status: "completed",
362
- blockers: ["test blocker"],
363
- });
364
-
365
- await extractLearnings(client as never, session);
366
-
367
- const lesson = client.createdEntities.find((e) => e.type === "lesson");
368
- const meta = lesson!.metadata as Record<string, unknown>;
369
- expect(meta.source).toBe("active_learning");
370
- expect(meta.card_id).toBe("card-abc");
371
- });
372
-
373
- test("lesson entity carries agent_identifier", async () => {
374
- const client = makeMockClient();
375
- const session = makeSession({
376
- agentIdentifier: "my-agent-v2",
377
- status: "completed",
378
- blockers: ["test blocker"],
379
- });
380
-
381
- await extractLearnings(client as never, session);
382
-
383
- const lesson = client.createdEntities.find((e) => e.type === "lesson");
384
- expect(lesson!.agent_identifier).toBe("my-agent-v2");
385
- });
386
-
387
- test("session lesson content includes agent name", async () => {
388
- const client = makeMockClient();
389
- const session = makeSession({
390
- agentName: "TestBot",
391
- status: "completed",
392
- blockers: ["test blocker"],
393
- });
394
-
395
- await extractLearnings(client as never, session);
396
-
397
- const lesson = client.createdEntities.find((e) => e.type === "lesson");
398
- expect(lesson!.content as string).toContain("TestBot");
399
- });
400
-
401
- test("clean completed session without meaningful content skips lesson", async () => {
402
- const client = makeMockClient();
403
- const session = makeSession({
404
- status: "completed",
405
- currentTask: undefined,
406
- progressPercent: undefined,
407
- sessionDurationMs: undefined,
408
- blockers: undefined,
409
- });
410
-
411
- const result = await extractLearnings(client as never, session);
412
- expect(result.count).toBe(0);
413
- const lessons = client.createdEntities.filter((e) => e.type === "lesson");
414
- expect(lessons).toHaveLength(0);
415
- });
416
- });
417
-
418
- describe("extractLearnings - returns correct entityIds", () => {
419
- test("entityIds array matches returned count", async () => {
420
- const client = makeMockClient();
421
- const session = makeSession({
422
- cardLabels: ["bug"],
423
- blockers: ["error occurred"],
424
- status: "completed",
425
- });
426
-
427
- const result = await extractLearnings(client as never, session);
428
-
429
- // error + lesson + solution = 3
430
- expect(result.count).toBe(result.entityIds.length);
431
- expect(result.entityIds).toHaveLength(3);
432
- // All IDs should be truthy strings
433
- for (const id of result.entityIds) {
434
- expect(typeof id).toBe("string");
435
- expect(id.length).toBeGreaterThan(0);
436
- }
437
- });
438
-
439
- test("entityIds are unique", async () => {
440
- const client = makeMockClient();
441
- const session = makeSession({
442
- cardLabels: ["bug"],
443
- blockers: ["err-1", "err-2"],
444
- status: "completed",
445
- });
446
-
447
- const result = await extractLearnings(client as never, session);
448
-
449
- const unique = new Set(result.entityIds);
450
- expect(unique.size).toBe(result.entityIds.length);
451
- });
452
- });
453
-
454
- describe("extractLearnings - fire-and-forget calls don't crash", () => {
455
- test("works with full client (autoExpandGraph and detectAndCreatePatterns paths)", async () => {
456
- const client = makeFullMockClient();
457
- const session = makeSession({ status: "completed", blockers: ["test"] });
458
-
459
- // Should not throw even when graph expansion / pattern detection run
460
- const result = await extractLearnings(client as never, session);
461
- expect(result.count).toBeGreaterThan(0);
462
-
463
- // Flush microtasks so fire-and-forget paths complete
464
- await new Promise((r) => setTimeout(r, 10));
465
-
466
- // searchMemoryEntities was called by autoExpandGraph
467
- expect(client.searchMemoryEntities).toHaveBeenCalled();
468
- });
469
-
470
- test("client errors in fire-and-forget paths do not surface to caller", async () => {
471
- const client = makeFullMockClient();
472
- // Make the search throw - should be swallowed
473
- client.searchMemoryEntities = mock(async () => {
474
- throw new Error("Search service unavailable");
475
- });
476
-
477
- const session = makeSession({ status: "completed", blockers: ["test"] });
478
- const result = await extractLearnings(client as never, session);
479
-
480
- // Main extraction still succeeded (error + lesson)
481
- expect(result.count).toBe(2);
482
- });
483
- });