@desplega.ai/agent-swarm 1.74.4 → 1.76.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.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +1264 -46
  3. package/package.json +2 -2
  4. package/src/be/db.ts +563 -9
  5. package/src/be/memory/edges-store.ts +69 -0
  6. package/src/be/memory/providers/sqlite-store.ts +4 -0
  7. package/src/be/memory/raters/explicit-self.ts +22 -0
  8. package/src/be/memory/raters/implicit-citation.ts +44 -0
  9. package/src/be/memory/raters/llm-client.ts +172 -0
  10. package/src/be/memory/raters/llm-summarizer.ts +218 -0
  11. package/src/be/memory/raters/llm.ts +375 -0
  12. package/src/be/memory/raters/noop.ts +14 -0
  13. package/src/be/memory/raters/registry.ts +86 -0
  14. package/src/be/memory/raters/retrieval.ts +88 -0
  15. package/src/be/memory/raters/run-server-raters.ts +97 -0
  16. package/src/be/memory/raters/store.ts +228 -0
  17. package/src/be/memory/raters/types.ts +101 -0
  18. package/src/be/memory/reranker.ts +32 -2
  19. package/src/be/memory/retrieval-store.ts +116 -0
  20. package/src/be/memory/types.ts +3 -0
  21. package/src/be/migrations/051_memory_posteriors_and_retrieval.sql +67 -0
  22. package/src/be/migrations/052_memory_edges.sql +36 -0
  23. package/src/be/migrations/053_agent_waiting_for_credentials_status.sql +61 -0
  24. package/src/be/migrations/054_agent_harness_provider.sql +21 -0
  25. package/src/be/migrations/055_agent_cred_status.sql +15 -0
  26. package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
  27. package/src/be/migrations/057_inbox_item_state.sql +27 -0
  28. package/src/be/migrations/058_task_templates.sql +31 -0
  29. package/src/be/swarm-config-guard.ts +24 -0
  30. package/src/commands/credential-wait.ts +186 -0
  31. package/src/commands/provider-credentials.ts +434 -0
  32. package/src/commands/runner.ts +253 -21
  33. package/src/hooks/hook.ts +143 -66
  34. package/src/http/agents.ts +191 -1
  35. package/src/http/config.ts +11 -1
  36. package/src/http/core.ts +5 -0
  37. package/src/http/inbox-state.ts +89 -0
  38. package/src/http/index.ts +10 -0
  39. package/src/http/memory.ts +230 -1
  40. package/src/http/sessions.ts +86 -0
  41. package/src/http/status.ts +665 -0
  42. package/src/http/task-templates.ts +51 -0
  43. package/src/http/tasks.ts +85 -5
  44. package/src/http/users.ts +134 -0
  45. package/src/prompts/memories.ts +62 -0
  46. package/src/providers/claude-adapter.ts +22 -0
  47. package/src/providers/claude-managed-adapter.ts +24 -0
  48. package/src/providers/codex-adapter.ts +43 -1
  49. package/src/providers/devin-adapter.ts +18 -0
  50. package/src/providers/index.ts +7 -0
  51. package/src/providers/opencode-adapter.ts +60 -0
  52. package/src/providers/pi-mono-adapter.ts +71 -0
  53. package/src/providers/types.ts +34 -0
  54. package/src/server.ts +2 -0
  55. package/src/slack/handlers.ts +0 -1
  56. package/src/tests/agents-harness-provider.test.ts +333 -0
  57. package/src/tests/credential-check.test.ts +367 -0
  58. package/src/tests/credential-status-api.test.ts +223 -0
  59. package/src/tests/credential-status-routing.test.ts +150 -0
  60. package/src/tests/credential-wait.test.ts +282 -0
  61. package/src/tests/harness-provider-resolution.test.ts +242 -0
  62. package/src/tests/jira-sync.test.ts +1 -1
  63. package/src/tests/memory-edges.test.ts +722 -0
  64. package/src/tests/memory-rate-endpoint.test.ts +330 -0
  65. package/src/tests/memory-rate-tool.test.ts +252 -0
  66. package/src/tests/memory-rater-e2e.test.ts +578 -0
  67. package/src/tests/memory-rater-implicit-citation.test.ts +304 -0
  68. package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
  69. package/src/tests/memory-rater-llm.test.ts +964 -0
  70. package/src/tests/memory-rater-store.test.ts +249 -0
  71. package/src/tests/memory-reranker.test.ts +161 -2
  72. package/src/tests/migration-runner-regressions.test.ts +17 -2
  73. package/src/tests/mocks/mock-llm-rater-client.ts +35 -0
  74. package/src/tests/run-server-raters.test.ts +291 -0
  75. package/src/tests/sessions.test.ts +141 -0
  76. package/src/tests/status.test.ts +843 -0
  77. package/src/tests/stop-hook-task-resolution.test.ts +98 -0
  78. package/src/tests/template-recommendations.test.ts +148 -0
  79. package/src/tests/tool-annotations.test.ts +2 -2
  80. package/src/tests/use-dismissible-card.test.ts +140 -0
  81. package/src/tools/memory-rate.ts +166 -0
  82. package/src/tools/memory-search.ts +18 -0
  83. package/src/tools/store-progress.ts +37 -0
  84. package/src/tools/swarm-config/set-config.ts +17 -1
  85. package/src/tools/tool-config.ts +1 -0
  86. package/src/types.ts +122 -1
  87. package/src/utils/harness-provider.ts +32 -0
  88. package/tsconfig.json +0 -2
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Tests for the memory-rater v1.5 step-6 wedge — `references-source` edges.
3
+ *
4
+ * Plan: thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-6.md §9
5
+ *
6
+ * Covers:
7
+ * - applyRating UPSERT into agent_memory_edge (single, repeated, distinct).
8
+ * - Q2 free-form contract — `linear:DES-187`, `customer:crabi`,
9
+ * `anything:goes-12345` all accepted; no prefix gating.
10
+ * - Q2 sanitization — NUL byte rejected, 513-char rejected, control chars
11
+ * stripped.
12
+ * - DB CHECK constraint trips for `type='supersedes'` (raw sqlite path).
13
+ * - HTTP `GET /api/memory/edges?memoryId=` happy + 400 paths.
14
+ * - `memory_rate` MCP tool round-trip with `referencesSource`.
15
+ * - LlmRater `buildRatingsFromLlm` propagation + sanitization.
16
+ * - Negative path: no edge row created when `referencesSource` is omitted.
17
+ */
18
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
+ import { randomUUID } from "node:crypto";
20
+ import { unlink } from "node:fs/promises";
21
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
+ import type { Subprocess } from "bun";
23
+ import { closeDb, createAgent, getDb, initDb } from "../be/db";
24
+ import { listEdgesForAgent } from "../be/memory/edges-store";
25
+ import { SqliteMemoryStore } from "../be/memory/providers/sqlite-store";
26
+ import { buildRatingsFromLlm } from "../be/memory/raters/llm";
27
+ import { applyRating } from "../be/memory/raters/store";
28
+ import { REFERENCES_SOURCE_MAX_LENGTH, sanitizeReferencesSource } from "../be/memory/raters/types";
29
+ import { registerMemoryRateTool } from "../tools/memory-rate";
30
+
31
+ const TEST_PORT = 19127;
32
+ const TEST_DB_PATH = `/tmp/test-memory-edges-${Date.now()}.sqlite`;
33
+ const BASE = `http://localhost:${TEST_PORT}`;
34
+ const API_KEY = "test-key";
35
+
36
+ let serverProc: Subprocess;
37
+ const agentA = randomUUID();
38
+ const agentB = randomUUID();
39
+ const taskA = randomUUID();
40
+ let store: SqliteMemoryStore;
41
+
42
+ const testTemplateGlobals = globalThis as typeof globalThis & {
43
+ __testMigrationTemplate?: Uint8Array;
44
+ __savedEdgesTemplate?: Uint8Array;
45
+ };
46
+
47
+ async function api(
48
+ method: string,
49
+ path: string,
50
+ opts: { body?: unknown; agentId?: string } = {},
51
+ // biome-ignore lint/suspicious/noExplicitAny: test helper
52
+ ): Promise<{ status: number; body: any }> {
53
+ const headers: Record<string, string> = {
54
+ "Content-Type": "application/json",
55
+ Authorization: `Bearer ${API_KEY}`,
56
+ };
57
+ if (opts.agentId) headers["x-agent-id"] = opts.agentId;
58
+ const res = await fetch(`${BASE}${path}`, {
59
+ method,
60
+ headers,
61
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
62
+ });
63
+ const text = await res.text();
64
+ // biome-ignore lint/suspicious/noExplicitAny: body may be JSON or text
65
+ let body: any;
66
+ try {
67
+ body = JSON.parse(text);
68
+ } catch {
69
+ body = text;
70
+ }
71
+ return { status: res.status, body };
72
+ }
73
+
74
+ async function waitForServer(url: string, timeoutMs = 15000): Promise<void> {
75
+ const start = Date.now();
76
+ while (Date.now() - start < timeoutMs) {
77
+ try {
78
+ const r = await fetch(url);
79
+ if (r.ok) return;
80
+ } catch {
81
+ // not ready
82
+ }
83
+ await Bun.sleep(50);
84
+ }
85
+ throw new Error(`Server did not start within ${timeoutMs}ms`);
86
+ }
87
+
88
+ function makeMemory(
89
+ name: string,
90
+ agentId = agentA,
91
+ scope: "agent" | "swarm" = "agent",
92
+ ): {
93
+ id: string;
94
+ } {
95
+ return store.store({
96
+ agentId,
97
+ scope,
98
+ name,
99
+ content: `${name} content`,
100
+ source: "manual",
101
+ });
102
+ }
103
+
104
+ function insertRetrieval(taskId: string, agentId: string, memoryId: string): void {
105
+ getDb()
106
+ .prepare(
107
+ `INSERT INTO memory_retrieval (id, taskId, agentId, sessionId, memoryId, similarity, retrievedAt)
108
+ VALUES (?, ?, ?, NULL, ?, 0.85, ?)`,
109
+ )
110
+ .run(randomUUID(), taskId, agentId, memoryId, new Date().toISOString());
111
+ }
112
+
113
+ function readPosterior(id: string): { alpha: number; beta: number } {
114
+ const row = getDb()
115
+ .prepare<{ alpha: number; beta: number }, [string]>(
116
+ "SELECT alpha, beta FROM agent_memory WHERE id = ?",
117
+ )
118
+ .get(id);
119
+ if (!row) throw new Error(`memory ${id} not found`);
120
+ return { alpha: row.alpha, beta: row.beta };
121
+ }
122
+
123
+ function readEdges(
124
+ memoryId: string,
125
+ ): { to_id: string; type: string; alpha: number; beta: number }[] {
126
+ return getDb()
127
+ .prepare<{ to_id: string; type: string; alpha: number; beta: number }, [string]>(
128
+ "SELECT to_id, type, alpha, beta FROM agent_memory_edge WHERE from_id = ? ORDER BY to_id",
129
+ )
130
+ .all(memoryId);
131
+ }
132
+
133
+ beforeAll(async () => {
134
+ for (const suffix of ["", "-wal", "-shm"]) {
135
+ try {
136
+ await unlink(TEST_DB_PATH + suffix);
137
+ } catch {}
138
+ }
139
+
140
+ serverProc = Bun.spawn(["bun", "src/http.ts"], {
141
+ cwd: `${import.meta.dir}/../..`,
142
+ env: {
143
+ ...process.env,
144
+ PORT: String(TEST_PORT),
145
+ DATABASE_PATH: TEST_DB_PATH,
146
+ API_KEY,
147
+ CAPABILITIES: "core",
148
+ SLACK_BOT_TOKEN: "",
149
+ LINEAR_DISABLE: "true",
150
+ JIRA_DISABLE: "true",
151
+ GITHUB_DISABLE: "true",
152
+ SLACK_DISABLE: "true",
153
+ HEARTBEAT_DISABLE: "true",
154
+ OAUTH_KEEPALIVE_DISABLE: "true",
155
+ ANONYMIZED_TELEMETRY: "false",
156
+ },
157
+ stdout: "ignore",
158
+ stderr: "ignore",
159
+ });
160
+
161
+ await waitForServer(`${BASE}/health`);
162
+
163
+ testTemplateGlobals.__savedEdgesTemplate = testTemplateGlobals.__testMigrationTemplate;
164
+ testTemplateGlobals.__testMigrationTemplate = undefined;
165
+ // Close any leftover in-memory DB from a prior test in the same Bun worker.
166
+ // initDb is a no-op when `db` is already set, so without this the test
167
+ // process keeps writing to the previous template-restored DB while the
168
+ // spawned server reads from TEST_DB_PATH — cross-process WAL visibility
169
+ // breaks and `applied=0` / 400 / empty edge lists ensue.
170
+ closeDb();
171
+ initDb(TEST_DB_PATH);
172
+ createAgent({ id: agentA, name: "Agent A", isLead: false, status: "idle" });
173
+ createAgent({ id: agentB, name: "Agent B", isLead: false, status: "idle" });
174
+
175
+ const insertTask = getDb().prepare(
176
+ `INSERT INTO agent_tasks (id, agentId, task, status, source, createdAt, lastUpdatedAt)
177
+ VALUES (?, ?, ?, 'in_progress', 'mcp', ?, ?)`,
178
+ );
179
+ const now = new Date().toISOString();
180
+ insertTask.run(taskA, agentA, "task A", now, now);
181
+
182
+ store = new SqliteMemoryStore();
183
+ }, 20000);
184
+
185
+ afterAll(async () => {
186
+ closeDb();
187
+ testTemplateGlobals.__testMigrationTemplate = testTemplateGlobals.__savedEdgesTemplate;
188
+ testTemplateGlobals.__savedEdgesTemplate = undefined;
189
+ if (serverProc) {
190
+ serverProc.kill();
191
+ try {
192
+ await serverProc.exited;
193
+ } catch {}
194
+ }
195
+ await Bun.sleep(50);
196
+ for (const suffix of ["", "-wal", "-shm"]) {
197
+ try {
198
+ await unlink(TEST_DB_PATH + suffix);
199
+ } catch {}
200
+ }
201
+ });
202
+
203
+ beforeEach(() => {
204
+ getDb().run("DELETE FROM memory_rating");
205
+ getDb().run("DELETE FROM memory_retrieval");
206
+ getDb().run("DELETE FROM agent_memory_edge");
207
+ getDb().run("UPDATE agent_memory SET alpha = 1.0, beta = 1.0");
208
+ });
209
+
210
+ describe("applyRating + agent_memory_edge UPSERT", () => {
211
+ test("first event creates the edge row with deltas matching the memory row", () => {
212
+ const m = makeMemory("ref-1");
213
+ const result = applyRating([
214
+ {
215
+ memoryId: m.id,
216
+ signal: 1,
217
+ weight: 1,
218
+ source: "llm",
219
+ referencesSource: "github:foo/bar#1",
220
+ },
221
+ ]);
222
+ expect(result.applied).toBe(1);
223
+ expect(readPosterior(m.id)).toEqual({ alpha: 2, beta: 1 });
224
+
225
+ const edges = readEdges(m.id);
226
+ expect(edges).toHaveLength(1);
227
+ expect(edges[0]).toMatchObject({
228
+ to_id: "github:foo/bar#1",
229
+ type: "references-source",
230
+ alpha: 2,
231
+ beta: 1,
232
+ });
233
+ });
234
+
235
+ test("repeated event with the same referencesSource updates the existing row in place", () => {
236
+ const m = makeMemory("ref-rep");
237
+ applyRating([
238
+ {
239
+ memoryId: m.id,
240
+ signal: 1,
241
+ weight: 0.5,
242
+ source: "llm",
243
+ referencesSource: "github:foo/bar#1",
244
+ },
245
+ ]);
246
+ applyRating([
247
+ {
248
+ memoryId: m.id,
249
+ signal: 1,
250
+ weight: 0.25,
251
+ source: "llm",
252
+ referencesSource: "github:foo/bar#1",
253
+ },
254
+ ]);
255
+ const edges = readEdges(m.id);
256
+ expect(edges).toHaveLength(1);
257
+ expect(edges[0]!.alpha).toBeCloseTo(1 + 0.5 + 0.25, 5);
258
+ expect(edges[0]!.beta).toBe(1);
259
+ });
260
+
261
+ test("different referencesSource for the same memory creates two distinct edge rows", () => {
262
+ const m = makeMemory("ref-distinct");
263
+ applyRating([
264
+ {
265
+ memoryId: m.id,
266
+ signal: 1,
267
+ weight: 1,
268
+ source: "llm",
269
+ referencesSource: "github:foo/bar#1",
270
+ },
271
+ ]);
272
+ applyRating([
273
+ {
274
+ memoryId: m.id,
275
+ signal: 1,
276
+ weight: 1,
277
+ source: "llm",
278
+ referencesSource: "linear:DES-187",
279
+ },
280
+ ]);
281
+ const edges = readEdges(m.id);
282
+ expect(edges).toHaveLength(2);
283
+ const ids = edges.map((e) => e.to_id).sort();
284
+ expect(ids).toEqual(["github:foo/bar#1", "linear:DES-187"]);
285
+ });
286
+
287
+ test("Q2 free-form: linear, customer, and arbitrary prefixes all accepted", () => {
288
+ const m = makeMemory("ref-freeform");
289
+ const sources = ["linear:DES-187", "customer:crabi", "anything:goes-12345"];
290
+ for (const referencesSource of sources) {
291
+ applyRating([
292
+ {
293
+ memoryId: m.id,
294
+ signal: 1,
295
+ weight: 1,
296
+ source: "llm",
297
+ referencesSource,
298
+ },
299
+ ]);
300
+ }
301
+ const edges = readEdges(m.id);
302
+ expect(edges).toHaveLength(3);
303
+ const ids = edges.map((e) => e.to_id).sort();
304
+ expect(ids).toEqual(sources.slice().sort());
305
+ });
306
+
307
+ test("event without referencesSource → memory updated, no edge row", () => {
308
+ const m = makeMemory("ref-none");
309
+ const result = applyRating([{ memoryId: m.id, signal: 1, weight: 1, source: "llm" }]);
310
+ expect(result.applied).toBe(1);
311
+ expect(readPosterior(m.id)).toEqual({ alpha: 2, beta: 1 });
312
+ expect(readEdges(m.id)).toEqual([]);
313
+ });
314
+
315
+ test("over-cap referencesSource (513 chars) → applyRating rejects, no edge or memory mutation", () => {
316
+ const m = makeMemory("ref-overcap");
317
+ const result = applyRating([
318
+ {
319
+ memoryId: m.id,
320
+ signal: 1,
321
+ weight: 1,
322
+ source: "llm",
323
+ referencesSource: "x".repeat(REFERENCES_SOURCE_MAX_LENGTH + 1),
324
+ },
325
+ ]);
326
+ expect(result.applied).toBe(0);
327
+ expect(result.rejected).toHaveLength(1);
328
+ expect(result.rejected[0]!.reason).toMatch(/exceeds/);
329
+ expect(readPosterior(m.id)).toEqual({ alpha: 1, beta: 1 });
330
+ expect(readEdges(m.id)).toEqual([]);
331
+ });
332
+
333
+ test("referencesSource with embedded NUL → applyRating rejects", () => {
334
+ const m = makeMemory("ref-nul");
335
+ const result = applyRating([
336
+ {
337
+ memoryId: m.id,
338
+ signal: 1,
339
+ weight: 1,
340
+ source: "llm",
341
+ referencesSource: `github:foo/bar#1${String.fromCharCode(0)}`,
342
+ },
343
+ ]);
344
+ expect(result.applied).toBe(0);
345
+ expect(result.rejected).toHaveLength(1);
346
+ expect(result.rejected[0]!.reason).toMatch(/NUL/);
347
+ expect(readEdges(m.id)).toEqual([]);
348
+ });
349
+ });
350
+
351
+ describe("DB CHECK constraint", () => {
352
+ test("INSERT with type='supersedes' raises SQLITE_CONSTRAINT (v2 guardrail)", () => {
353
+ const m = makeMemory("check-supersedes");
354
+ expect(() => {
355
+ getDb().run(
356
+ `INSERT INTO agent_memory_edge (from_id, to_id, type, alpha, beta, createdAt)
357
+ VALUES (?, ?, 'supersedes', 1, 1, ?)`,
358
+ [m.id, "memory:other", new Date().toISOString()],
359
+ );
360
+ }).toThrow(/CHECK|constraint/i);
361
+ });
362
+ });
363
+
364
+ describe("sanitizeReferencesSource (Q2)", () => {
365
+ test("strips control characters, preserves printable ASCII", () => {
366
+ const cleaned = sanitizeReferencesSource(
367
+ `github:foo/bar#1${String.fromCharCode(7)}${String.fromCharCode(127)}`,
368
+ );
369
+ expect(cleaned).toBe("github:foo/bar#1");
370
+ });
371
+
372
+ test("rejects strings containing a NUL byte", () => {
373
+ expect(sanitizeReferencesSource(`a${String.fromCharCode(0)}b`)).toBeNull();
374
+ });
375
+
376
+ test("rejects strings that strip to empty", () => {
377
+ expect(sanitizeReferencesSource(String.fromCharCode(7).repeat(5))).toBeNull();
378
+ });
379
+ });
380
+
381
+ describe("buildRatingsFromLlm propagation (step-6 §6)", () => {
382
+ test("propagates valid referencesSource through to RatingEvent", () => {
383
+ const events = buildRatingsFromLlm(
384
+ [
385
+ {
386
+ id: "m-1",
387
+ score: 1,
388
+ reasoning: "useful",
389
+ referencesSource: "linear:DES-187",
390
+ },
391
+ ],
392
+ [{ id: "m-1" }],
393
+ );
394
+ expect(events).toHaveLength(1);
395
+ expect(events[0]!.referencesSource).toBe("linear:DES-187");
396
+ });
397
+
398
+ test("drops referencesSource when it sanitizes to null (NUL byte) but keeps the rating", () => {
399
+ const events = buildRatingsFromLlm(
400
+ [
401
+ {
402
+ id: "m-1",
403
+ score: 0.8,
404
+ reasoning: "useful with bad ref",
405
+ referencesSource: `linear:DES-187${String.fromCharCode(0)}`,
406
+ },
407
+ ],
408
+ [{ id: "m-1" }],
409
+ );
410
+ expect(events).toHaveLength(1);
411
+ expect(events[0]!.referencesSource).toBeUndefined();
412
+ expect(events[0]!.signal).toBeCloseTo(2 * 0.8 - 1, 5);
413
+ });
414
+
415
+ test("omits referencesSource when not provided (default behaviour)", () => {
416
+ const events = buildRatingsFromLlm(
417
+ [{ id: "m-1", score: 1, reasoning: "useful" }],
418
+ [{ id: "m-1" }],
419
+ );
420
+ expect(events).toHaveLength(1);
421
+ expect(events[0]!.referencesSource).toBeUndefined();
422
+ });
423
+ });
424
+
425
+ describe("POST /api/memory/rate with referencesSource (step-6 §4)", () => {
426
+ test("happy path: referencesSource accepted → edge row created", async () => {
427
+ const m = makeMemory("http-rate-1");
428
+ const r = await api("POST", "/api/memory/rate", {
429
+ agentId: agentA,
430
+ body: {
431
+ events: [
432
+ {
433
+ memoryId: m.id,
434
+ signal: 1,
435
+ weight: 1,
436
+ source: "llm",
437
+ taskId: taskA,
438
+ referencesSource: "github:desplega-ai/agent-swarm#377",
439
+ },
440
+ ],
441
+ },
442
+ });
443
+ expect(r.status).toBe(200);
444
+ expect(r.body.applied).toBe(1);
445
+ const edges = readEdges(m.id);
446
+ expect(edges).toHaveLength(1);
447
+ expect(edges[0]!.to_id).toBe("github:desplega-ai/agent-swarm#377");
448
+ });
449
+
450
+ test("Q2 free-form positives (linear/customer/anything) all accepted via HTTP", async () => {
451
+ const m = makeMemory("http-rate-freeform");
452
+ for (const referencesSource of ["linear:DES-187", "customer:crabi", "anything:goes-12345"]) {
453
+ const r = await api("POST", "/api/memory/rate", {
454
+ agentId: agentA,
455
+ body: {
456
+ events: [
457
+ {
458
+ memoryId: m.id,
459
+ signal: 1,
460
+ weight: 1,
461
+ source: "llm",
462
+ taskId: taskA,
463
+ referencesSource,
464
+ },
465
+ ],
466
+ },
467
+ });
468
+ expect(r.status).toBe(200);
469
+ expect(r.body.applied).toBe(1);
470
+ }
471
+ const edges = readEdges(m.id);
472
+ expect(edges).toHaveLength(3);
473
+ });
474
+
475
+ test("513-char referencesSource → 400 (Zod cap)", async () => {
476
+ const m = makeMemory("http-rate-overcap");
477
+ const r = await api("POST", "/api/memory/rate", {
478
+ agentId: agentA,
479
+ body: {
480
+ events: [
481
+ {
482
+ memoryId: m.id,
483
+ signal: 1,
484
+ weight: 1,
485
+ source: "llm",
486
+ taskId: taskA,
487
+ referencesSource: "x".repeat(REFERENCES_SOURCE_MAX_LENGTH + 1),
488
+ },
489
+ ],
490
+ },
491
+ });
492
+ expect(r.status).toBe(400);
493
+ expect(readEdges(m.id)).toEqual([]);
494
+ });
495
+
496
+ test("embedded NUL → 400 (Zod transform rejects)", async () => {
497
+ const m = makeMemory("http-rate-nul");
498
+ const r = await api("POST", "/api/memory/rate", {
499
+ agentId: agentA,
500
+ body: {
501
+ events: [
502
+ {
503
+ memoryId: m.id,
504
+ signal: 1,
505
+ weight: 1,
506
+ source: "llm",
507
+ taskId: taskA,
508
+ referencesSource: `github:foo/bar#1${String.fromCharCode(0)}`,
509
+ },
510
+ ],
511
+ },
512
+ });
513
+ expect(r.status).toBe(400);
514
+ expect(readEdges(m.id)).toEqual([]);
515
+ });
516
+ });
517
+
518
+ describe("GET /api/memory/edges (step-6 §7)", () => {
519
+ test("returns edges with computed usefulness", async () => {
520
+ const m = makeMemory("get-edges-1");
521
+ applyRating([
522
+ {
523
+ memoryId: m.id,
524
+ signal: 1,
525
+ weight: 0.5,
526
+ source: "llm",
527
+ referencesSource: "github:foo/bar#1",
528
+ },
529
+ ]);
530
+
531
+ const r = await api("GET", `/api/memory/edges?memoryId=${m.id}`, { agentId: agentA });
532
+ expect(r.status).toBe(200);
533
+ expect(Array.isArray(r.body.edges)).toBe(true);
534
+ expect(r.body.edges).toHaveLength(1);
535
+ expect(r.body.edges[0]).toMatchObject({
536
+ to: "github:foo/bar#1",
537
+ type: "references-source",
538
+ });
539
+ expect(r.body.edges[0].alpha).toBeCloseTo(1.5, 5);
540
+ expect(r.body.edges[0].beta).toBe(1);
541
+ // usefulness = clamp(2 * 1.5/(1.5+1), 1.0, 2.0) = 1.2
542
+ expect(r.body.edges[0].usefulness).toBeCloseTo(1.2, 5);
543
+ expect(typeof r.body.edges[0].createdAt).toBe("string");
544
+ });
545
+
546
+ test("returns empty array when memory has no edges", async () => {
547
+ const m = makeMemory("get-edges-empty");
548
+ const r = await api("GET", `/api/memory/edges?memoryId=${m.id}`, { agentId: agentA });
549
+ expect(r.status).toBe(200);
550
+ expect(r.body.edges).toEqual([]);
551
+ });
552
+
553
+ test("missing memoryId → 400", async () => {
554
+ const r = await api("GET", "/api/memory/edges", { agentId: agentA });
555
+ expect(r.status).toBe(400);
556
+ });
557
+
558
+ test("missing X-Agent-ID → 400", async () => {
559
+ const m = makeMemory("get-edges-noauth");
560
+ const r = await api("GET", `/api/memory/edges?memoryId=${m.id}`);
561
+ expect(r.status).toBe(400);
562
+ });
563
+
564
+ test("agent-scope memory owned by another agent → empty (defence-in-depth)", async () => {
565
+ const m = makeMemory("get-edges-other-owner", agentB);
566
+ applyRating([
567
+ {
568
+ memoryId: m.id,
569
+ signal: 1,
570
+ weight: 1,
571
+ source: "llm",
572
+ referencesSource: "github:other/repo#1",
573
+ },
574
+ ]);
575
+ const r = await api("GET", `/api/memory/edges?memoryId=${m.id}`, { agentId: agentA });
576
+ expect(r.status).toBe(200);
577
+ expect(r.body.edges).toEqual([]);
578
+ });
579
+
580
+ test("swarm-scope memory is visible to any agent", async () => {
581
+ const m = makeMemory("get-edges-swarm", agentB, "swarm");
582
+ applyRating([
583
+ {
584
+ memoryId: m.id,
585
+ signal: 1,
586
+ weight: 1,
587
+ source: "llm",
588
+ referencesSource: "github:swarm/repo#1",
589
+ },
590
+ ]);
591
+ const r = await api("GET", `/api/memory/edges?memoryId=${m.id}`, { agentId: agentA });
592
+ expect(r.status).toBe(200);
593
+ expect(r.body.edges).toHaveLength(1);
594
+ expect(r.body.edges[0].to).toBe("github:swarm/repo#1");
595
+ });
596
+ });
597
+
598
+ describe("listEdgesForAgent (in-process)", () => {
599
+ test("returns empty for unknown memory id", () => {
600
+ expect(listEdgesForAgent(agentA, randomUUID())).toEqual([]);
601
+ });
602
+
603
+ test("clamps usefulness to [1.0, 2.0]", () => {
604
+ const m = makeMemory("usefulness-clamp");
605
+ applyRating([
606
+ {
607
+ memoryId: m.id,
608
+ signal: 1,
609
+ weight: 1,
610
+ source: "llm",
611
+ referencesSource: "github:foo/bar#1",
612
+ },
613
+ ]);
614
+ const edges = listEdgesForAgent(agentA, m.id);
615
+ expect(edges).toHaveLength(1);
616
+ expect(edges[0]!.usefulness).toBeGreaterThanOrEqual(1.0);
617
+ expect(edges[0]!.usefulness).toBeLessThanOrEqual(2.0);
618
+ });
619
+ });
620
+
621
+ describe("memory_rate MCP tool with referencesSource (step-6 §5)", () => {
622
+ type FetchInit = Parameters<typeof fetch>[1];
623
+ type CallRecord = { url: string; init: FetchInit };
624
+ const originalFetch = globalThis.fetch;
625
+
626
+ function installFetchStub(
627
+ responder: (url: string, init: FetchInit) => Response | Promise<Response>,
628
+ ): { calls: CallRecord[] } {
629
+ const calls: CallRecord[] = [];
630
+ globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: FetchInit) => {
631
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : "";
632
+ calls.push({ url, init: init ?? {} });
633
+ return responder(url, init ?? {});
634
+ }) as typeof fetch;
635
+ return { calls };
636
+ }
637
+
638
+ function buildServer() {
639
+ const server = new McpServer({ name: "memory-rate-test", version: "1.0.0" });
640
+ registerMemoryRateTool(server);
641
+ type RegisteredTool = {
642
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
643
+ };
644
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
645
+ ._registeredTools;
646
+ const tool = registered.memory_rate;
647
+ if (!tool) throw new Error("memory_rate tool not registered");
648
+ return { tool };
649
+ }
650
+
651
+ const fakeMeta = {
652
+ sessionId: "session-123",
653
+ requestInfo: {
654
+ headers: {
655
+ "x-agent-id": "agent-abc",
656
+ "x-source-task-id": "11111111-1111-4111-8111-111111111111",
657
+ },
658
+ },
659
+ };
660
+ const fakeMemoryId = "22222222-2222-4222-8222-222222222222";
661
+
662
+ test("forwards referencesSource in the POST body when provided", async () => {
663
+ const { tool } = buildServer();
664
+ const { calls } = installFetchStub(() => new Response("{}", { status: 200 }));
665
+ try {
666
+ const result = (await tool.handler(
667
+ { id: fakeMemoryId, useful: true, referencesSource: "linear:DES-187" },
668
+ fakeMeta,
669
+ )) as { structuredContent: { success: boolean } };
670
+ expect(result.structuredContent.success).toBe(true);
671
+ expect(calls).toHaveLength(1);
672
+ const body = JSON.parse(calls[0]!.init?.body as string);
673
+ expect(body.events[0].referencesSource).toBe("linear:DES-187");
674
+ } finally {
675
+ globalThis.fetch = originalFetch;
676
+ }
677
+ });
678
+
679
+ test("rejects NUL-byte referencesSource without POSTing", async () => {
680
+ const { tool } = buildServer();
681
+ const { calls } = installFetchStub(() => new Response("{}", { status: 200 }));
682
+ try {
683
+ const result = (await tool.handler(
684
+ {
685
+ id: fakeMemoryId,
686
+ useful: true,
687
+ referencesSource: `linear:DES-187${String.fromCharCode(0)}`,
688
+ },
689
+ fakeMeta,
690
+ )) as { structuredContent: { success: boolean; message: string } };
691
+ expect(result.structuredContent.success).toBe(false);
692
+ expect(result.structuredContent.message).toMatch(/NUL/);
693
+ expect(calls).toHaveLength(0);
694
+ } finally {
695
+ globalThis.fetch = originalFetch;
696
+ }
697
+ });
698
+
699
+ test("end-to-end: rate + retrieval row → 200 + edge row exists", async () => {
700
+ const m = makeMemory("mcp-roundtrip");
701
+ insertRetrieval(taskA, agentA, m.id);
702
+
703
+ const r = await api("POST", "/api/memory/rate", {
704
+ agentId: agentA,
705
+ body: {
706
+ events: [
707
+ {
708
+ memoryId: m.id,
709
+ signal: 1,
710
+ weight: 1,
711
+ source: "explicit-self",
712
+ taskId: taskA,
713
+ referencesSource: "github:desplega-ai/agent-swarm#377",
714
+ },
715
+ ],
716
+ },
717
+ });
718
+ expect(r.status).toBe(200);
719
+ expect(r.body.applied).toBe(1);
720
+ expect(readEdges(m.id)).toHaveLength(1);
721
+ });
722
+ });