@gethmy/mcp 2.4.6 → 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,438 +0,0 @@
1
- /**
2
- * Unit tests for the pattern detection logic (detectAndCreatePatterns).
3
- *
4
- * Run with: bun test packages/mcp-server/src/__tests__/pattern-detection.test.ts
5
- */
6
-
7
- import { describe, expect, mock, test } from "bun:test";
8
-
9
- // Mock config before importing
10
- mock.module("../config.js", () => ({
11
- getActiveWorkspaceId: () => "ws-test",
12
- getActiveProjectId: () => "proj-test",
13
- isConfigured: () => true,
14
- loadConfig: () => {},
15
- }));
16
-
17
- // Mock graph-expansion so autoExpandGraph calls don't interfere with pattern tests
18
- mock.module("../graph-expansion.js", () => ({
19
- autoExpandGraph: mock(async () => ({ relationsCreated: 0 })),
20
- }));
21
-
22
- const { detectAndCreatePatterns } = await import("../active-learning.js");
23
- type SessionContext = import("../active-learning.js").SessionContext;
24
-
25
- // ---- Mock helpers ----
26
-
27
- interface MockEntity {
28
- id: string;
29
- type: string;
30
- confidence?: number;
31
- tags?: string[];
32
- }
33
-
34
- function makeFullMockClient(opts: {
35
- entity?: { id: string; type: string };
36
- searchResults?: MockEntity[];
37
- existingPatterns?: MockEntity[];
38
- }) {
39
- const createdEntities: Array<Record<string, unknown>> = [];
40
- const createdRelations: Array<Record<string, unknown>> = [];
41
- let nextId = 100;
42
-
43
- return {
44
- createdEntities,
45
- createdRelations,
46
-
47
- getMemoryEntity: mock(async (_id: string) => ({
48
- entity: opts.entity ?? { id: _id, type: "lesson" },
49
- })),
50
-
51
- searchMemoryEntities: mock(async () => ({
52
- entities: opts.searchResults ?? [],
53
- count: opts.searchResults?.length ?? 0,
54
- })),
55
-
56
- listMemoryEntities: mock(async () => ({
57
- entities: opts.existingPatterns ?? [],
58
- count: opts.existingPatterns?.length ?? 0,
59
- })),
60
-
61
- createMemoryEntity: mock(async (data: Record<string, unknown>) => {
62
- const entity = { id: `pattern-${nextId++}`, ...data };
63
- createdEntities.push(entity);
64
- return { entity };
65
- }),
66
-
67
- updateMemoryEntity: mock(
68
- async (id: string, updates: Record<string, unknown>) => ({
69
- entity: { id, ...updates },
70
- }),
71
- ),
72
-
73
- createMemoryRelation: mock(async (data: Record<string, unknown>) => {
74
- createdRelations.push(data);
75
- return { relation: { id: `rel-${nextId++}`, ...data } };
76
- }),
77
- };
78
- }
79
-
80
- function makeSession(overrides: Partial<SessionContext> = {}): SessionContext {
81
- return {
82
- cardId: "card-1",
83
- cardTitle: "Implement feature X",
84
- cardLabels: ["backend", "api"],
85
- agentIdentifier: "claude-code",
86
- agentName: "Claude Code",
87
- status: "completed",
88
- ...overrides,
89
- };
90
- }
91
-
92
- // ---- Tests ----
93
-
94
- describe("detectAndCreatePatterns", () => {
95
- describe("threshold gating", () => {
96
- test("does nothing when fewer than 3 similar entities exist", async () => {
97
- const client = makeFullMockClient({
98
- entity: { id: "new-1", type: "lesson" },
99
- searchResults: [
100
- { id: "old-1", type: "lesson" },
101
- { id: "old-2", type: "lesson" },
102
- ],
103
- });
104
-
105
- await detectAndCreatePatterns(
106
- client as never,
107
- ["new-1"],
108
- makeSession(),
109
- "ws-test",
110
- );
111
-
112
- expect(client.createMemoryEntity).not.toHaveBeenCalled();
113
- expect(client.createMemoryRelation).not.toHaveBeenCalled();
114
- });
115
-
116
- test("creates pattern when exactly 3 similar entities exist", async () => {
117
- const client = makeFullMockClient({
118
- entity: { id: "new-1", type: "lesson" },
119
- searchResults: [
120
- { id: "old-1", type: "lesson" },
121
- { id: "old-2", type: "lesson" },
122
- { id: "old-3", type: "lesson" },
123
- ],
124
- });
125
-
126
- await detectAndCreatePatterns(
127
- client as never,
128
- ["new-1"],
129
- makeSession(),
130
- "ws-test",
131
- );
132
-
133
- expect(client.createMemoryEntity).toHaveBeenCalledTimes(1);
134
- const created = client.createdEntities[0];
135
- expect(created.type).toBe("pattern");
136
- expect(created.memory_tier).toBe("reference");
137
- expect(created.confidence).toBe(0.75);
138
- });
139
-
140
- test("creates pattern when more than 3 similar entities exist", async () => {
141
- const client = makeFullMockClient({
142
- entity: { id: "new-1", type: "error" },
143
- searchResults: Array.from({ length: 10 }, (_, i) => ({
144
- id: `old-${i}`,
145
- type: "error",
146
- })),
147
- });
148
-
149
- await detectAndCreatePatterns(
150
- client as never,
151
- ["new-1"],
152
- makeSession(),
153
- "ws-test",
154
- );
155
-
156
- expect(client.createMemoryEntity).toHaveBeenCalledTimes(1);
157
- });
158
- });
159
-
160
- describe("newly-created entities are excluded from match count", () => {
161
- test("newEntityIds are excluded from the existing count", async () => {
162
- // 3 search results but 2 of them are in newEntityIds → only 1 truly existing
163
- const client = makeFullMockClient({
164
- entity: { id: "new-1", type: "lesson" },
165
- searchResults: [
166
- { id: "new-2", type: "lesson" }, // also new - should be excluded
167
- { id: "new-3", type: "lesson" }, // also new - should be excluded
168
- { id: "old-1", type: "lesson" }, // truly existing
169
- ],
170
- });
171
-
172
- await detectAndCreatePatterns(
173
- client as never,
174
- ["new-1", "new-2", "new-3"],
175
- makeSession(),
176
- "ws-test",
177
- );
178
-
179
- // Only 1 truly existing → below threshold → no pattern
180
- expect(client.createMemoryEntity).not.toHaveBeenCalled();
181
- });
182
- });
183
-
184
- describe("existing pattern update", () => {
185
- test("updates existing pattern entity instead of creating a new one", async () => {
186
- const client = makeFullMockClient({
187
- entity: { id: "new-1", type: "lesson" },
188
- searchResults: Array.from({ length: 5 }, (_, i) => ({
189
- id: `old-${i}`,
190
- type: "lesson",
191
- })),
192
- existingPatterns: [{ id: "existing-pattern", type: "pattern" }],
193
- });
194
-
195
- await detectAndCreatePatterns(
196
- client as never,
197
- ["new-1"],
198
- makeSession(),
199
- "ws-test",
200
- );
201
-
202
- // No new pattern entity created
203
- expect(client.createMemoryEntity).not.toHaveBeenCalled();
204
- // Existing one was updated
205
- expect(client.updateMemoryEntity).toHaveBeenCalledWith(
206
- "existing-pattern",
207
- expect.objectContaining({
208
- content: expect.stringContaining("Recurring pattern"),
209
- metadata: expect.objectContaining({
210
- pattern_count: expect.any(Number),
211
- }),
212
- }),
213
- );
214
- });
215
-
216
- test("creates relation from existing pattern to new entity", async () => {
217
- const client = makeFullMockClient({
218
- entity: { id: "new-1", type: "lesson" },
219
- searchResults: Array.from({ length: 4 }, (_, i) => ({
220
- id: `old-${i}`,
221
- type: "lesson",
222
- })),
223
- existingPatterns: [{ id: "existing-pattern", type: "pattern" }],
224
- });
225
-
226
- await detectAndCreatePatterns(
227
- client as never,
228
- ["new-1"],
229
- makeSession(),
230
- "ws-test",
231
- );
232
-
233
- const relationsFromPattern = client.createdRelations.filter(
234
- (r) => r.source_id === "existing-pattern",
235
- );
236
- expect(relationsFromPattern.length).toBeGreaterThan(0);
237
- const targetIds = relationsFromPattern.map((r) => r.target_id);
238
- expect(targetIds).toContain("new-1");
239
- });
240
- });
241
-
242
- describe("new pattern creation", () => {
243
- test("pattern entity has correct metadata", async () => {
244
- const session = makeSession({ cardLabels: ["backend", "api"] });
245
- const client = makeFullMockClient({
246
- entity: { id: "new-1", type: "error" },
247
- searchResults: Array.from({ length: 4 }, (_, i) => ({
248
- id: `old-${i}`,
249
- type: "error",
250
- })),
251
- });
252
-
253
- await detectAndCreatePatterns(
254
- client as never,
255
- ["new-1"],
256
- session,
257
- "ws-test",
258
- "proj-test",
259
- );
260
-
261
- const pattern = client.createdEntities[0];
262
- expect(pattern.type).toBe("pattern");
263
- expect(pattern.memory_tier).toBe("reference");
264
- expect(pattern.confidence).toBe(0.75);
265
- expect(pattern.workspace_id).toBe("ws-test");
266
- expect(pattern.project_id).toBe("proj-test");
267
- const meta = pattern.metadata as Record<string, unknown>;
268
- expect(meta.source).toBe("pattern_detection");
269
- expect(meta.pattern_type).toBe("error");
270
- });
271
-
272
- test("pattern title references the entity type", async () => {
273
- const client = makeFullMockClient({
274
- entity: { id: "new-1", type: "solution" },
275
- searchResults: Array.from({ length: 3 }, (_, i) => ({
276
- id: `old-${i}`,
277
- type: "solution",
278
- })),
279
- });
280
-
281
- await detectAndCreatePatterns(
282
- client as never,
283
- ["new-1"],
284
- makeSession({ cardLabels: ["backend", "api"] }),
285
- "ws-test",
286
- );
287
-
288
- const pattern = client.createdEntities[0];
289
- expect(pattern.title as string).toContain("Pattern:");
290
- expect(pattern.title as string).toContain("solution");
291
- });
292
-
293
- test("creates relates_to relations from pattern to source entities", async () => {
294
- const client = makeFullMockClient({
295
- entity: { id: "new-1", type: "lesson" },
296
- searchResults: [
297
- { id: "old-1", type: "lesson" },
298
- { id: "old-2", type: "lesson" },
299
- { id: "old-3", type: "lesson" },
300
- ],
301
- });
302
-
303
- await detectAndCreatePatterns(
304
- client as never,
305
- ["new-1"],
306
- makeSession(),
307
- "ws-test",
308
- );
309
-
310
- // Relations should link pattern → new entity + up to 4 existing
311
- const relations = client.createdRelations;
312
- expect(relations.length).toBeGreaterThan(0);
313
- for (const rel of relations) {
314
- expect(rel.relation_type).toBe("relates_to");
315
- expect(rel.confidence).toBe(0.75);
316
- }
317
- const targetIds = relations.map((r) => r.target_id);
318
- expect(targetIds).toContain("new-1");
319
- });
320
- });
321
-
322
- describe("error handling", () => {
323
- test("is non-fatal when getMemoryEntity throws", async () => {
324
- const client = {
325
- getMemoryEntity: mock(async () => {
326
- throw new Error("Not found");
327
- }),
328
- searchMemoryEntities: mock(async () => ({ entities: [], count: 0 })),
329
- listMemoryEntities: mock(async () => ({ entities: [], count: 0 })),
330
- createMemoryEntity: mock(async () => ({ entity: { id: "x" } })),
331
- updateMemoryEntity: mock(async () => ({ entity: {} })),
332
- createMemoryRelation: mock(async () => ({ relation: {} })),
333
- };
334
-
335
- // Should not throw
336
- const result = await detectAndCreatePatterns(
337
- client as never,
338
- ["new-1"],
339
- makeSession(),
340
- "ws-test",
341
- );
342
-
343
- expect(result).toEqual([]);
344
- });
345
-
346
- test("skips entity if type is missing", async () => {
347
- const client = makeFullMockClient({
348
- // entity with no type
349
- entity: undefined,
350
- searchResults: [],
351
- });
352
- // Override getMemoryEntity to return entity without type
353
- client.getMemoryEntity = mock(async () => ({
354
- entity: { id: "new-1" } as { id: string; type: string },
355
- }));
356
-
357
- await detectAndCreatePatterns(
358
- client as never,
359
- ["new-1"],
360
- makeSession(),
361
- "ws-test",
362
- );
363
-
364
- expect(client.createMemoryEntity).not.toHaveBeenCalled();
365
- });
366
-
367
- test("duplicate relation failures do not abort remaining relations", async () => {
368
- let relCount = 0;
369
- const client = makeFullMockClient({
370
- entity: { id: "new-1", type: "lesson" },
371
- searchResults: [
372
- { id: "old-1", type: "lesson" },
373
- { id: "old-2", type: "lesson" },
374
- { id: "old-3", type: "lesson" },
375
- ],
376
- });
377
- // First relation call throws 409, rest succeed
378
- client.createMemoryRelation = mock(
379
- async (data: Record<string, unknown>) => {
380
- relCount++;
381
- if (relCount === 1)
382
- throw Object.assign(new Error("Conflict"), { status: 409 });
383
- client.createdRelations.push(data);
384
- return { relation: {} };
385
- },
386
- );
387
-
388
- // Should not throw
389
- await detectAndCreatePatterns(
390
- client as never,
391
- ["new-1"],
392
- makeSession(),
393
- "ws-test",
394
- );
395
-
396
- // Pattern was still created despite relation error
397
- expect(client.createdEntities).toHaveLength(1);
398
- });
399
- });
400
-
401
- describe("multiple new entities in one session", () => {
402
- test("processes each new entity independently", async () => {
403
- let callCount = 0;
404
- const entityTypes = ["lesson", "error"];
405
-
406
- const client = {
407
- getMemoryEntity: mock(async (id: string) => ({
408
- entity: { id, type: entityTypes[callCount++ % 2] ?? "lesson" },
409
- })),
410
- searchMemoryEntities: mock(async () => ({
411
- entities: Array.from({ length: 4 }, (_, i) => ({
412
- id: `old-${i}`,
413
- type: "lesson",
414
- })),
415
- count: 4,
416
- })),
417
- listMemoryEntities: mock(async () => ({ entities: [], count: 0 })),
418
- createMemoryEntity: mock(async (data: Record<string, unknown>) => ({
419
- entity: { id: `pat-${Math.random()}`, ...data },
420
- })),
421
- updateMemoryEntity: mock(async () => ({ entity: {} })),
422
- createMemoryRelation: mock(async () => ({ relation: {} })),
423
- createdEntities: [] as Array<Record<string, unknown>>,
424
- createdRelations: [] as Array<Record<string, unknown>>,
425
- };
426
-
427
- await detectAndCreatePatterns(
428
- client as never,
429
- ["new-1", "new-2"],
430
- makeSession(),
431
- "ws-test",
432
- );
433
-
434
- // Both new entities processed → 2 pattern entities potentially created
435
- expect(client.getMemoryEntity).toHaveBeenCalledTimes(2);
436
- });
437
- });
438
- });