@desplega.ai/agent-swarm 1.98.1 → 1.99.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,10 +1,11 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import { chunkContent } from "../be/chunking";
4
- import { getTaskById } from "../be/db";
4
+ import { getDb, getTaskById } from "../be/db";
5
5
  import { getEmbeddingProvider, getMemoryStore } from "../be/memory";
6
6
  import { CANDIDATE_SET_MULTIPLIER } from "../be/memory/constants";
7
7
  import { listEdgesForAgent } from "../be/memory/edges-store";
8
+ import { storeLinks } from "../be/memory/link-resolver";
8
9
  import { recordRetrievals } from "../be/memory/raters/retrieval";
9
10
  import { applyRating, ExplicitSelfDuplicateError } from "../be/memory/raters/store";
10
11
  import {
@@ -37,6 +38,7 @@ const indexMemory = route({
37
38
  sourcePath: z.string().optional(),
38
39
  tags: z.array(z.string()).optional(),
39
40
  persistMemory: z.boolean().optional(),
41
+ contextKey: z.string().optional(),
40
42
  }),
41
43
  responses: {
42
44
  202: { description: "Content queued for embedding" },
@@ -53,6 +55,13 @@ const searchMemory = route({
53
55
  auth: { apiKey: true, agentId: true },
54
56
  body: z.object({
55
57
  query: z.string().min(1),
58
+ intent: z
59
+ .string()
60
+ .min(1)
61
+ .optional()
62
+ .describe(
63
+ "Why you are searching. Required for agent recall-edge tracking; omit for UI browse/search calls.",
64
+ ),
56
65
  limit: z.number().int().min(1).max(20).default(5),
57
66
  scope: z.enum(["agent", "swarm", "all"]).default("all"),
58
67
  source: z.enum(["manual", "file_index", "session_summary", "task_completion"]).optional(),
@@ -149,6 +158,15 @@ const getMemoryById = route({
149
158
  tags: ["Memory"],
150
159
  auth: { apiKey: true, agentId: true },
151
160
  params: z.object({ id: z.string().uuid() }),
161
+ query: z.object({
162
+ intent: z
163
+ .string()
164
+ .min(1)
165
+ .optional()
166
+ .describe(
167
+ "Why you are retrieving this memory. Required for agent recall-edge tracking; omit for UI browse calls.",
168
+ ),
169
+ }),
152
170
  responses: {
153
171
  200: { description: "Memory details" },
154
172
  404: { description: "Memory not found" },
@@ -266,8 +284,18 @@ export async function handleMemory(
266
284
  const parsed = await indexMemory.parse(req, res, pathSegments, new URLSearchParams());
267
285
  if (!parsed) return true;
268
286
 
269
- const { agentId, content, name, scope, source, sourceTaskId, sourcePath, tags, persistMemory } =
270
- parsed.body;
287
+ const {
288
+ agentId,
289
+ content,
290
+ name,
291
+ scope,
292
+ source,
293
+ sourceTaskId,
294
+ sourcePath,
295
+ tags,
296
+ persistMemory,
297
+ contextKey,
298
+ } = parsed.body;
271
299
 
272
300
  if (source === "session_summary" && sourceTaskId) {
273
301
  const sourceTask = getTaskById(sourceTaskId);
@@ -296,6 +324,13 @@ export async function handleMemory(
296
324
  store.deleteBySourcePath(sourcePath, agentId);
297
325
  }
298
326
 
327
+ // Derive contextKey from body or X-Context-Key header
328
+ const headerContextKey = req.headers["x-context-key"];
329
+ const resolvedContextKey =
330
+ contextKey ??
331
+ (Array.isArray(headerContextKey) ? headerContextKey[0] : headerContextKey) ??
332
+ undefined;
333
+
299
334
  // Atomic batch insert — all chunks or none
300
335
  const memories = store.storeBatch(
301
336
  contentChunks.map((chunk) => ({
@@ -309,9 +344,24 @@ export async function handleMemory(
309
344
  chunkIndex: chunk.chunkIndex,
310
345
  totalChunks: chunk.totalChunks,
311
346
  tags: tags || [],
347
+ contextKey: resolvedContextKey ?? null,
312
348
  })),
313
349
  );
314
350
 
351
+ // Resolve and store deterministic links (wikilinks, PR refs, agent-fs paths)
352
+ if (agentId) {
353
+ for (const memory of memories) {
354
+ try {
355
+ storeLinks(memory.id, agentId, memory.content);
356
+ } catch (err) {
357
+ console.error(
358
+ `[memory] Link resolution failed for ${memory.id}:`,
359
+ (err as Error).message,
360
+ );
361
+ }
362
+ }
363
+ }
364
+
315
365
  // Async batch embed (fire and forget)
316
366
  (async () => {
317
367
  try {
@@ -339,7 +389,7 @@ export async function handleMemory(
339
389
  const parsed = await searchMemory.parse(req, res, pathSegments, new URLSearchParams());
340
390
  if (!parsed) return true;
341
391
 
342
- const { query, limit, scope, source } = parsed.body;
392
+ const { query, intent, limit, scope, source } = parsed.body;
343
393
 
344
394
  try {
345
395
  const provider = getEmbeddingProvider();
@@ -369,12 +419,16 @@ export async function handleMemory(
369
419
  const sourceTaskId = Array.isArray(sourceTaskIdHeader)
370
420
  ? sourceTaskIdHeader[0]
371
421
  : sourceTaskIdHeader;
372
- if (sourceTaskId) {
422
+ const contextKeyHeader = req.headers["x-context-key"];
423
+ const contextKey = Array.isArray(contextKeyHeader) ? contextKeyHeader[0] : contextKeyHeader;
424
+ if (sourceTaskId && intent) {
373
425
  try {
374
426
  recordRetrievals(
375
427
  sourceTaskId,
376
428
  myAgentId,
377
429
  ranked.map((r) => ({ memoryId: r.id, similarity: r.similarity })),
430
+ undefined,
431
+ { intent, contextKey, eventType: "search" },
378
432
  );
379
433
  } catch (err) {
380
434
  console.error("[memory-search] recordRetrievals failed:", (err as Error).message);
@@ -620,7 +674,11 @@ export async function handleMemory(
620
674
  reasoning: e.reasoning,
621
675
  ...(e.referencesSource !== undefined ? { referencesSource: e.referencesSource } : {}),
622
676
  }));
623
- const result = applyRating(ratingEvents, { taskId });
677
+ const rateContextKeyHeader = req.headers["x-context-key"];
678
+ const rateContextKey = Array.isArray(rateContextKeyHeader)
679
+ ? rateContextKeyHeader[0]
680
+ : rateContextKeyHeader;
681
+ const result = applyRating(ratingEvents, { taskId, contextKey: rateContextKey });
624
682
  applied += result.applied;
625
683
  for (const r of result.rejected) {
626
684
  rejected.push({ memoryId: r.event.memoryId, reason: r.reason });
@@ -671,7 +729,8 @@ export async function handleMemory(
671
729
  }
672
730
 
673
731
  if (getMemoryById.match(req.method, pathSegments)) {
674
- const parsed = await getMemoryById.parse(req, res, pathSegments, new URLSearchParams());
732
+ const queryParams = parseQueryParams(req.url || "");
733
+ const parsed = await getMemoryById.parse(req, res, pathSegments, queryParams);
675
734
  if (!parsed) return true;
676
735
 
677
736
  const memory = getMemoryStore().get(parsed.params.id);
@@ -680,6 +739,27 @@ export async function handleMemory(
680
739
  return true;
681
740
  }
682
741
 
742
+ const { intent } = parsed.query;
743
+ const sourceTaskIdHeader = req.headers["x-source-task-id"];
744
+ const sourceTaskId = Array.isArray(sourceTaskIdHeader)
745
+ ? sourceTaskIdHeader[0]
746
+ : sourceTaskIdHeader;
747
+ const contextKeyHeader = req.headers["x-context-key"];
748
+ const contextKey = Array.isArray(contextKeyHeader) ? contextKeyHeader[0] : contextKeyHeader;
749
+ if (sourceTaskId && myAgentId && intent) {
750
+ try {
751
+ recordRetrievals(
752
+ sourceTaskId,
753
+ myAgentId,
754
+ [{ memoryId: memory.id, similarity: 1.0 }],
755
+ undefined,
756
+ { intent, contextKey, eventType: "get" },
757
+ );
758
+ } catch (err) {
759
+ console.error("[memory-get] recordRetrievals failed:", (err as Error).message);
760
+ }
761
+ }
762
+
683
763
  json(res, { memory });
684
764
  return true;
685
765
  }
@@ -692,6 +772,23 @@ export async function handleMemory(
692
772
  const MEMORY_GC_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
693
773
  let memoryGcTimer: ReturnType<typeof setInterval> | null = null;
694
774
 
775
+ const SEARCH_RETRIEVAL_TTL_DAYS = 90;
776
+
777
+ function purgeStaleSearchRetrievals(): number {
778
+ try {
779
+ const cutoff = new Date(
780
+ Date.now() - SEARCH_RETRIEVAL_TTL_DAYS * 24 * 60 * 60 * 1000,
781
+ ).toISOString();
782
+ const result = getDb()
783
+ .prepare("DELETE FROM memory_retrieval WHERE eventType = 'search' AND retrievedAt < ?")
784
+ .run(cutoff);
785
+ return result.changes;
786
+ } catch (err) {
787
+ console.error("[memory-gc] Search retrieval purge failed:", (err as Error).message);
788
+ return 0;
789
+ }
790
+ }
791
+
695
792
  export function startMemoryGc(intervalMs = MEMORY_GC_INTERVAL_MS): void {
696
793
  if (memoryGcTimer) return;
697
794
 
@@ -701,6 +798,12 @@ export function startMemoryGc(intervalMs = MEMORY_GC_INTERVAL_MS): void {
701
798
  if (purged > 0) {
702
799
  console.log(`[memory-gc] Initial purge removed ${purged} expired memory row(s)`);
703
800
  }
801
+ const searchPurged = purgeStaleSearchRetrievals();
802
+ if (searchPurged > 0) {
803
+ console.log(
804
+ `[memory-gc] Initial purge removed ${searchPurged} stale search retrieval row(s)`,
805
+ );
806
+ }
704
807
  } catch (err) {
705
808
  console.error("[memory-gc] Initial purge failed:", err);
706
809
  }
@@ -711,6 +814,12 @@ export function startMemoryGc(intervalMs = MEMORY_GC_INTERVAL_MS): void {
711
814
  if (purged > 0) {
712
815
  console.log(`[memory-gc] Periodic purge removed ${purged} expired memory row(s)`);
713
816
  }
817
+ const searchPurged = purgeStaleSearchRetrievals();
818
+ if (searchPurged > 0) {
819
+ console.log(
820
+ `[memory-gc] Periodic purge removed ${searchPurged} stale search retrieval row(s)`,
821
+ );
822
+ }
714
823
  } catch (err) {
715
824
  console.error("[memory-gc] Periodic purge failed:", err);
716
825
  }
@@ -248,6 +248,7 @@ export function mergeMcpConfig(
248
248
  baseConfig: { mcpServers?: Record<string, unknown> } | null,
249
249
  installedServers: Record<string, Record<string, unknown>> | null,
250
250
  taskId: string,
251
+ contextKey?: string,
251
252
  ): { mcpServers: Record<string, unknown> } {
252
253
  const config: { mcpServers: Record<string, unknown> } = {
253
254
  mcpServers: { ...(baseConfig?.mcpServers ?? {}) },
@@ -273,6 +274,9 @@ export function mergeMcpConfig(
273
274
  const server = config.mcpServers[serverKey] as Record<string, unknown>;
274
275
  if (!server.headers) server.headers = {};
275
276
  (server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
277
+ if (contextKey) {
278
+ (server.headers as Record<string, string>)["X-Context-Key"] = contextKey;
279
+ }
276
280
  }
277
281
 
278
282
  return config;
@@ -291,6 +295,7 @@ export async function createSessionMcpConfig(
291
295
  cwd: string,
292
296
  taskId: string,
293
297
  installedServers?: Record<string, Record<string, unknown>> | null,
298
+ contextKey?: string,
294
299
  ): Promise<string | null> {
295
300
  // Collect every .mcp.json from cwd up to filesystem root. Stopping at the first
296
301
  // match silently drops the swarm-managed /workspace/.mcp.json when the cloned
@@ -341,7 +346,12 @@ export async function createSessionMcpConfig(
341
346
  }
342
347
 
343
348
  try {
344
- const config = mergeMcpConfig({ mcpServers: mergedServers }, installedServers ?? null, taskId);
349
+ const config = mergeMcpConfig(
350
+ { mcpServers: mergedServers },
351
+ installedServers ?? null,
352
+ taskId,
353
+ contextKey,
354
+ );
345
355
  const sessionConfigPath = `/tmp/mcp-${taskId}.json`;
346
356
  await writeFile(sessionConfigPath, JSON.stringify(config, null, 2));
347
357
  return sessionConfigPath;
@@ -950,11 +960,12 @@ export class ClaudeAdapter implements ProviderAdapter {
950
960
  );
951
961
  }
952
962
 
953
- // Create per-session MCP config with X-Source-Task-Id header + installed servers (no shared-file race condition)
963
+ // Create per-session MCP config with X-Source-Task-Id + X-Context-Key headers + installed servers (no shared-file race condition)
954
964
  const sessionMcpConfig = await createSessionMcpConfig(
955
965
  config.cwd,
956
966
  config.taskId,
957
967
  installedServers,
968
+ config.contextKey,
958
969
  );
959
970
 
960
971
  // Stage the system prompt on disk so it can be passed as a file path
@@ -92,6 +92,7 @@ export interface ProviderSessionConfig {
92
92
  apiKey: string;
93
93
  cwd: string;
94
94
  vcsRepo?: string;
95
+ contextKey?: string;
95
96
  /**
96
97
  * @deprecated Never set by the runner — native session resume was removed in
97
98
  * the 2026-05-28 plan. Adapters log + ignore any stray value. Follow-up
@@ -56,7 +56,11 @@ function bridgeRequestFor(name: string, args: unknown): BridgeRequest | null {
56
56
  case "memory_get": {
57
57
  const memoryId = typeof body.memoryId === "string" ? body.memoryId : undefined;
58
58
  if (!memoryId) throw new Error("memory_get requires string `memoryId`");
59
- return { method: "GET", path: `/api/memory/${encodeURIComponent(memoryId)}` };
59
+ const getIntent = typeof body.intent === "string" ? body.intent : "script-sdk";
60
+ return {
61
+ method: "GET",
62
+ path: `/api/memory/${encodeURIComponent(memoryId)}?intent=${encodeURIComponent(getIntent)}`,
63
+ };
60
64
  }
61
65
  case "memory_rate": {
62
66
  const event = {
@@ -50,11 +50,12 @@ declare module "swarm-sdk" {
50
50
  // --- memory ---
51
51
  memory_search(args: {
52
52
  query: string;
53
+ intent: string;
53
54
  scope?: "all" | "agent" | "swarm";
54
55
  limit?: number;
55
56
  source?: string;
56
57
  }): Promise<unknown>;
57
- memory_get(args: { memoryId: string }): Promise<unknown>;
58
+ memory_get(args: { memoryId: string; intent: string }): Promise<unknown>;
58
59
  memory_rate(args: { id: string; useful: boolean; note?: string }): Promise<unknown>;
59
60
  // --- tasks ---
60
61
  task_list(args?: Record<string, unknown>): Promise<unknown>;
@@ -32,11 +32,12 @@ declare module "swarm-sdk" {
32
32
  // --- memory ---
33
33
  memory_search(args: {
34
34
  query: string;
35
+ intent: string;
35
36
  scope?: "all" | "agent" | "swarm";
36
37
  limit?: number;
37
38
  source?: string;
38
39
  }): Promise<unknown>;
39
- memory_get(args: { memoryId: string }): Promise<unknown>;
40
+ memory_get(args: { memoryId: string; intent: string }): Promise<unknown>;
40
41
  memory_rate(args: { id: string; useful: boolean; note?: string }): Promise<unknown>;
41
42
  // --- tasks ---
42
43
  task_list(args?: Record<string, unknown>): Promise<unknown>;
@@ -1,7 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Type } from "typebox";
3
3
  import { z } from "zod";
4
- import { completeStructured } from "../../utils/internal-ai/complete-structured.js";
4
+ import {
5
+ completeStructured,
6
+ defaultSpawnClaudeCli,
7
+ } from "../../utils/internal-ai/complete-structured.js";
5
8
  import type { ResolvedCredential } from "../../utils/internal-ai/credentials.js";
6
9
 
7
10
  const ResultZodSchema = z.object({
@@ -274,3 +277,33 @@ describe("completeStructured", () => {
274
277
  expect(match).toBeDefined();
275
278
  });
276
279
  });
280
+
281
+ describe("defaultSpawnClaudeCli", () => {
282
+ test("sets SKIP_SESSION_SUMMARY=1 in the child env (Stop-hook recursion guard)", async () => {
283
+ // Without this guard, the spawned `claude -p` summarizer session fires the
284
+ // same global Stop hook on exit, which spawns another summarizer claude,
285
+ // recursively — observed OOM-wedging 8GB E2B worker sandboxes.
286
+ const fakeBinary = `/tmp/fake-claude-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`;
287
+ await Bun.write(
288
+ fakeBinary,
289
+ '#!/usr/bin/env bash\ncat >/dev/null\nprintf \'{"result":"SKIP_SESSION_SUMMARY=%s"}\' "$SKIP_SESSION_SUMMARY"\n',
290
+ );
291
+ const savedBinary = process.env.CLAUDE_BINARY;
292
+ const savedSkip = process.env.SKIP_SESSION_SUMMARY;
293
+ const savedBridge = process.env.SWARM_USE_CLAUDE_BRIDGE;
294
+ process.env.CLAUDE_BINARY = `bash ${fakeBinary}`;
295
+ // Ensure a false-pass via inheritance is impossible.
296
+ delete process.env.SKIP_SESSION_SUMMARY;
297
+ delete process.env.SWARM_USE_CLAUDE_BRIDGE;
298
+ try {
299
+ const out = await defaultSpawnClaudeCli("prompt", "haiku");
300
+ expect(out).toBe("SKIP_SESSION_SUMMARY=1");
301
+ } finally {
302
+ if (savedBinary === undefined) delete process.env.CLAUDE_BINARY;
303
+ else process.env.CLAUDE_BINARY = savedBinary;
304
+ if (savedSkip !== undefined) process.env.SKIP_SESSION_SUMMARY = savedSkip;
305
+ if (savedBridge !== undefined) process.env.SWARM_USE_CLAUDE_BRIDGE = savedBridge;
306
+ await Bun.$`rm -f ${fakeBinary}`.quiet();
307
+ }
308
+ });
309
+ });
@@ -0,0 +1,172 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { randomUUID } from "node:crypto";
3
+ import { unlink } from "node:fs/promises";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+ import { Readable } from "node:stream";
6
+ import { closeDb, createAgent, getDb, initDb } from "../be/db";
7
+ import type { AgentMemory } from "../types";
8
+
9
+ const memoryId = randomUUID();
10
+ const agentId = randomUUID();
11
+ const sourceTaskId = randomUUID();
12
+ const TEST_DB_PATH = "./test-memory-http-recall-gating.sqlite";
13
+
14
+ const memory: AgentMemory = {
15
+ id: memoryId,
16
+ agentId,
17
+ content: "UI browse/search memory fixture",
18
+ name: "ui-memory-fixture",
19
+ scope: "agent",
20
+ source: "manual",
21
+ sourcePath: null,
22
+ sourceTaskId: null,
23
+ chunkIndex: 0,
24
+ totalChunks: 1,
25
+ tags: [],
26
+ contextKey: null,
27
+ createdAt: new Date("2026-06-14T00:00:00.000Z").toISOString(),
28
+ updatedAt: new Date("2026-06-14T00:00:00.000Z").toISOString(),
29
+ };
30
+
31
+ mock.module("../be/memory", () => ({
32
+ getEmbeddingProvider: () => ({
33
+ name: "test-embedding",
34
+ dimensions: 3,
35
+ embed: async () => new Float32Array([1, 0, 0]),
36
+ embedBatch: async (texts: string[]) => texts.map(() => new Float32Array([1, 0, 0])),
37
+ }),
38
+ getMemoryStore: () => ({
39
+ get: () => memory,
40
+ search: () => [
41
+ {
42
+ ...memory,
43
+ similarity: 0.95,
44
+ rawSimilarity: 0.95,
45
+ compositeScore: 0.95,
46
+ accessCount: 0,
47
+ expiresAt: null,
48
+ embeddingModel: "test-embedding",
49
+ alpha: 1,
50
+ beta: 1,
51
+ },
52
+ ],
53
+ }),
54
+ }));
55
+
56
+ const { handleMemory } = await import("../http/memory");
57
+
58
+ type ResponseCapture = {
59
+ statusCode: number;
60
+ body: any;
61
+ };
62
+
63
+ function makeReq(
64
+ method: string,
65
+ url: string,
66
+ body?: unknown,
67
+ headers: Record<string, string> = {},
68
+ ): IncomingMessage {
69
+ const chunks = body === undefined ? [] : [Buffer.from(JSON.stringify(body))];
70
+ const req = Readable.from(chunks) as IncomingMessage;
71
+ req.method = method;
72
+ req.url = url;
73
+ req.headers = headers;
74
+ return req;
75
+ }
76
+
77
+ function makeRes(capture: ResponseCapture): ServerResponse {
78
+ return {
79
+ writeHead(statusCode: number) {
80
+ capture.statusCode = statusCode;
81
+ return this;
82
+ },
83
+ end(chunk?: unknown) {
84
+ capture.body = typeof chunk === "string" ? JSON.parse(chunk) : chunk;
85
+ return this;
86
+ },
87
+ } as ServerResponse;
88
+ }
89
+
90
+ async function callMemoryRoute(
91
+ method: string,
92
+ url: string,
93
+ pathSegments: string[],
94
+ body?: unknown,
95
+ headers: Record<string, string> = {},
96
+ ): Promise<ResponseCapture> {
97
+ const capture: ResponseCapture = { statusCode: 0, body: null };
98
+ const handled = await handleMemory(
99
+ makeReq(method, url, body, headers),
100
+ makeRes(capture),
101
+ pathSegments,
102
+ agentId,
103
+ );
104
+ expect(handled).toBe(true);
105
+ return capture;
106
+ }
107
+
108
+ function countRetrievals(): number {
109
+ return getDb().prepare<{ n: number }, []>("SELECT COUNT(*) AS n FROM memory_retrieval").get()!.n;
110
+ }
111
+
112
+ beforeAll(async () => {
113
+ for (const suffix of ["", "-wal", "-shm"]) {
114
+ try {
115
+ await unlink(TEST_DB_PATH + suffix);
116
+ } catch {}
117
+ }
118
+
119
+ initDb(TEST_DB_PATH);
120
+ createAgent({ id: agentId, name: "HTTP Memory Gating Agent", isLead: false, status: "idle" });
121
+ const nowIso = new Date().toISOString();
122
+ getDb()
123
+ .prepare(
124
+ `INSERT INTO agent_tasks (id, agentId, task, status, source, createdAt, lastUpdatedAt)
125
+ VALUES (?, ?, ?, 'in_progress', 'mcp', ?, ?)`,
126
+ )
127
+ .run(sourceTaskId, agentId, "HTTP memory recall gating task", nowIso, nowIso);
128
+ });
129
+
130
+ beforeEach(() => {
131
+ getDb().run("DELETE FROM memory_retrieval");
132
+ });
133
+
134
+ afterAll(async () => {
135
+ closeDb();
136
+ for (const suffix of ["", "-wal", "-shm"]) {
137
+ try {
138
+ await unlink(TEST_DB_PATH + suffix);
139
+ } catch {}
140
+ }
141
+ });
142
+
143
+ describe("memory HTTP recall capture gating", () => {
144
+ test("POST /api/memory/search accepts UI calls without intent and does not record retrievals", async () => {
145
+ const response = await callMemoryRoute(
146
+ "POST",
147
+ "/api/memory/search",
148
+ ["api", "memory", "search"],
149
+ { query: "UI browse/search", limit: 5 },
150
+ { "x-source-task-id": sourceTaskId, "x-context-key": "task:ui-browse" },
151
+ );
152
+
153
+ expect(response.statusCode).toBe(200);
154
+ expect(response.body.results).toHaveLength(1);
155
+ expect(response.body.results[0].id).toBe(memoryId);
156
+ expect(countRetrievals()).toBe(0);
157
+ });
158
+
159
+ test("GET /api/memory/:id accepts UI calls without intent and does not record retrievals", async () => {
160
+ const response = await callMemoryRoute(
161
+ "GET",
162
+ `/api/memory/${memoryId}`,
163
+ ["api", "memory", memoryId],
164
+ undefined,
165
+ { "x-source-task-id": sourceTaskId, "x-context-key": "task:ui-browse" },
166
+ );
167
+
168
+ expect(response.statusCode).toBe(200);
169
+ expect(response.body.memory.id).toBe(memoryId);
170
+ expect(countRetrievals()).toBe(0);
171
+ });
172
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveLinks } from "../be/memory/link-resolver";
3
+
4
+ describe("resolveLinks", () => {
5
+ test("extracts wikilinks from content", () => {
6
+ const links = resolveLinks("See [[auth-fix-pattern]] and [[pr585-codex-binary]] for context.");
7
+ expect(links).toHaveLength(2);
8
+ expect(links[0]).toMatchObject({
9
+ linkType: "wikilink",
10
+ targetKind: "memory",
11
+ targetId: "auth-fix-pattern",
12
+ resolver: "wikilink",
13
+ });
14
+ expect(links[1]).toMatchObject({
15
+ linkType: "wikilink",
16
+ targetKind: "memory",
17
+ targetId: "pr585-codex-binary",
18
+ resolver: "wikilink",
19
+ });
20
+ });
21
+
22
+ test("extracts PR references with hash notation", () => {
23
+ const links = resolveLinks("Fixed in #696 and PR #470.");
24
+ const prLinks = links.filter((l) => l.linkType === "pr");
25
+ expect(prLinks.length).toBeGreaterThanOrEqual(2);
26
+ const ids = prLinks.map((l) => l.targetId);
27
+ expect(ids).toContain("pr:696");
28
+ expect(ids).toContain("pr:470");
29
+ });
30
+
31
+ test("extracts full GitHub PR URLs", () => {
32
+ const links = resolveLinks(
33
+ "See https://github.com/desplega-ai/agent-swarm/pull/763 for the fix.",
34
+ );
35
+ const prLinks = links.filter((l) => l.linkType === "pr");
36
+ expect(prLinks).toHaveLength(1);
37
+ expect(prLinks[0]).toMatchObject({
38
+ linkType: "pr",
39
+ targetKind: "pr",
40
+ targetId: "github:desplega-ai/agent-swarm#763",
41
+ resolver: "pr-url",
42
+ });
43
+ });
44
+
45
+ test("extracts agent-fs paths", () => {
46
+ const links = resolveLinks(
47
+ "Plan at live.agent-fs.dev/file/~/648a5f3c-35c8-4f11-8673-b89de52cd6bd/2faf73ba-4eee-4472-8b3b-359c4ed6bfbb/thoughts/plan.md",
48
+ );
49
+ const fsLinks = links.filter((l) => l.linkType === "agent-fs-file");
50
+ expect(fsLinks).toHaveLength(1);
51
+ expect(fsLinks[0]!.targetKind).toBe("agent-fs-file");
52
+ expect(fsLinks[0]!.resolver).toBe("agent-fs-path");
53
+ });
54
+
55
+ test("extracts agent-ui page links", () => {
56
+ const links = resolveLinks(
57
+ "See app.agent-swarm.dev/pages/abc12345-1234-1234-1234-123456789abc",
58
+ );
59
+ const uiLinks = links.filter((l) => l.linkType === "agent-ui");
60
+ expect(uiLinks).toHaveLength(1);
61
+ expect(uiLinks[0]).toMatchObject({
62
+ targetKind: "agent-ui",
63
+ targetId: "page:abc12345-1234-1234-1234-123456789abc",
64
+ resolver: "agent-ui-page",
65
+ });
66
+ });
67
+
68
+ test("deduplicates PR references", () => {
69
+ const links = resolveLinks("PR #696, see also PR #696 again, and #696 once more.");
70
+ const prLinks = links.filter((l) => l.linkType === "pr");
71
+ const ids696 = prLinks.filter((l) => l.targetId === "pr:696");
72
+ expect(ids696).toHaveLength(1);
73
+ });
74
+
75
+ test("returns empty array for content without links", () => {
76
+ const links = resolveLinks("This is plain text with no links or references.");
77
+ expect(links).toHaveLength(0);
78
+ });
79
+
80
+ test("handles mixed content with multiple link types", () => {
81
+ const content = `
82
+ See [[memory-search-fix]] for context.
83
+ PR #696 fixed the embedding issue.
84
+ Plan: live.agent-fs.dev/file/~/648a5f3c-35c8-4f11-8673-b89de52cd6bd/2faf73ba/thoughts/plan.md
85
+ `;
86
+ const links = resolveLinks(content);
87
+ const types = new Set(links.map((l) => l.linkType));
88
+ expect(types.has("wikilink")).toBe(true);
89
+ expect(types.has("pr")).toBe(true);
90
+ expect(types.has("agent-fs-file")).toBe(true);
91
+ });
92
+ });
@@ -682,12 +682,15 @@ describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
682
682
 
683
683
  expect(lastCreateOpencodeConfig).toBeDefined();
684
684
  const opts = lastCreateOpencodeConfig as {
685
+ timeout?: number;
685
686
  config?: {
686
687
  model?: string;
687
688
  mcp?: Record<string, unknown>;
688
689
  permission?: Record<string, string>;
689
690
  };
690
691
  };
692
+ // Server-start timeout must override the SDK's 5s default (E2B cold-start flake)
693
+ expect(opts.timeout).toBe(30_000);
691
694
  expect(opts.config?.model).toBe("claude-sonnet-4-6");
692
695
  expect(opts.config?.mcp?.swarm).toBeDefined();
693
696
  const swarm = opts.config?.mcp?.swarm as {
@@ -453,7 +453,7 @@ describe("collectProfilePayloads (baseline integration)", () => {
453
453
  [SOUL_MD_PATH]: LONG,
454
454
  [IDENTITY_MD_PATH]: LONG,
455
455
  [TOOLS_MD_PATH]: "tools",
456
- ["/workspace/HEARTBEAT.md"]: "heartbeat",
456
+ "/workspace/HEARTBEAT.md": "heartbeat",
457
457
  [IDENTITY_BASELINES_PATH]: JSON.stringify(baselines),
458
458
  });
459
459