@desplega.ai/agent-swarm 1.91.0 → 1.92.1

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 (114) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +1005 -152
  3. package/package.json +6 -6
  4. package/plugin/skills/pages/SKILL.md +5 -2
  5. package/src/be/db.ts +662 -19
  6. package/src/be/memory/constants.ts +2 -1
  7. package/src/be/memory/providers/openai-embedding.ts +2 -5
  8. package/src/be/memory/providers/sqlite-store.ts +293 -76
  9. package/src/be/memory/types.ts +35 -0
  10. package/src/be/migrations/083_script_workflows.sql +51 -0
  11. package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
  12. package/src/be/migrations/085_script_runs_kind.sql +9 -0
  13. package/src/be/migrations/086_pages_default_authed.sql +64 -0
  14. package/src/be/migrations/087_skill_files.sql +19 -0
  15. package/src/be/modelsdev-cache.json +42310 -38617
  16. package/src/be/scripts/typecheck.ts +49 -0
  17. package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
  18. package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
  19. package/src/be/seed-scripts/catalog/compound-insights.ts +310 -6
  20. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
  21. package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
  22. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +506 -0
  23. package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
  24. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  25. package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
  26. package/src/be/seed-scripts/catalog/tool-usage.ts +6 -3
  27. package/src/be/seed-scripts/index.ts +51 -5
  28. package/src/be/seed-skills/index.ts +3 -3
  29. package/src/be/skill-sync.ts +91 -7
  30. package/src/be/swarm-config-guard.ts +17 -0
  31. package/src/commands/runner.ts +49 -4
  32. package/src/heartbeat/templates.ts +20 -16
  33. package/src/http/db-query.ts +20 -5
  34. package/src/http/index.ts +51 -7
  35. package/src/http/mcp-user.ts +23 -0
  36. package/src/http/mcp.ts +58 -0
  37. package/src/http/memory.ts +58 -0
  38. package/src/http/pages.ts +1 -1
  39. package/src/http/script-runs.ts +557 -0
  40. package/src/http/scripts.ts +39 -2
  41. package/src/http/skills.ts +225 -0
  42. package/src/prompts/session-templates.ts +24 -4
  43. package/src/providers/claude-adapter.ts +107 -28
  44. package/src/script-workflows/executor.ts +110 -0
  45. package/src/script-workflows/harness.ts +73 -0
  46. package/src/script-workflows/label-lint.ts +51 -0
  47. package/src/script-workflows/limits.ts +22 -0
  48. package/src/script-workflows/supervisor.ts +139 -0
  49. package/src/script-workflows/workflow-ctx.ts +209 -0
  50. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  51. package/src/scripts-runtime/swarm-sdk.ts +13 -0
  52. package/src/scripts-runtime/types/stdlib.d.ts +61 -0
  53. package/src/scripts-runtime/types/swarm-sdk.d.ts +61 -0
  54. package/src/server.ts +4 -0
  55. package/src/slack/handlers.ts +11 -4
  56. package/src/slack/message-text.ts +98 -0
  57. package/src/slack/thread-buffer.ts +5 -3
  58. package/src/tests/claude-adapter-binary.test.ts +271 -74
  59. package/src/tests/create-page-tool.test.ts +19 -2
  60. package/src/tests/db-query.test.ts +28 -0
  61. package/src/tests/error-tracker.test.ts +121 -0
  62. package/src/tests/harness-provider-resolution.test.ts +33 -0
  63. package/src/tests/heartbeat-checklist.test.ts +36 -0
  64. package/src/tests/mcp-tools.test.ts +6 -0
  65. package/src/tests/mcp-transport-gc.test.ts +58 -0
  66. package/src/tests/memory-health-endpoint.test.ts +78 -0
  67. package/src/tests/memory-store.test.ts +221 -1
  68. package/src/tests/pages-http.test.ts +20 -2
  69. package/src/tests/pages-storage.test.ts +26 -0
  70. package/src/tests/prompt-template-session.test.ts +34 -5
  71. package/src/tests/script-runs-http.test.ts +278 -0
  72. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  73. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  74. package/src/tests/scripts-mcp-e2e.test.ts +102 -2
  75. package/src/tests/seed-scripts.test.ts +468 -3
  76. package/src/tests/skill-files-http.test.ts +171 -0
  77. package/src/tests/skill-files.test.ts +162 -0
  78. package/src/tests/skill-get-file-tool.test.ts +110 -0
  79. package/src/tests/skill-sync.test.ts +125 -6
  80. package/src/tests/slack-message-text.test.ts +250 -0
  81. package/src/tests/system-default-skills.test.ts +40 -0
  82. package/src/tools/create-page.ts +2 -2
  83. package/src/tools/db-query.ts +16 -6
  84. package/src/tools/script-runs.ts +123 -0
  85. package/src/tools/skills/index.ts +1 -0
  86. package/src/tools/skills/skill-get-file.ts +80 -0
  87. package/src/tools/slack-read.ts +12 -3
  88. package/src/tools/tool-config.ts +6 -2
  89. package/src/types.ts +72 -0
  90. package/src/utils/error-tracker.ts +40 -1
  91. package/src/utils/internal-ai/complete-structured.ts +10 -4
  92. package/src/workflows/executors/raw-llm.ts +76 -59
  93. package/templates/schedules/daily-blocker-digest/content.md +68 -54
  94. package/templates/schedules/daily-compounding-reflection/content.md +4 -4
  95. package/templates/schedules/daily-hn-briefing/content.md +5 -5
  96. package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
  97. package/templates/schedules/gtm-weekly-review/content.md +9 -9
  98. package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
  99. package/templates/skills/agentmail-sending/content.md +6 -7
  100. package/templates/skills/desloppify/content.md +8 -9
  101. package/templates/skills/jira-interaction/content.md +25 -33
  102. package/templates/skills/kapso-whatsapp/content.md +29 -30
  103. package/templates/skills/linear-interaction/content.md +8 -9
  104. package/templates/skills/pages/content.md +205 -55
  105. package/templates/skills/profile-corruption-escalation/content.md +44 -85
  106. package/templates/skills/script-workflows/config.json +14 -0
  107. package/templates/skills/script-workflows/content.md +68 -0
  108. package/templates/skills/sprite-cli/content.md +4 -5
  109. package/templates/skills/swarm-scripts/content.md +2 -3
  110. package/templates/skills/turso-interaction/content.md +14 -17
  111. package/templates/skills/workflow-iterate/content.md +38 -391
  112. package/templates/skills/x-api-interactions/content.md +4 -6
  113. package/templates/skills/scheduled-task-resilience/config.json +0 -14
  114. package/templates/skills/scheduled-task-resilience/content.md +0 -95
@@ -63,14 +63,13 @@ describe("create_page MCP tool", () => {
63
63
  }
64
64
  });
65
65
 
66
- test("first call creates a row + returns shareable URLs", async () => {
66
+ test("first call creates an authed row by default + returns shareable URLs", async () => {
67
67
  const tool = buildServer();
68
68
  const result = (await tool.handler(
69
69
  {
70
70
  title: "Hello Page",
71
71
  body: "<h1>hello</h1>",
72
72
  contentType: "text/html",
73
- authMode: "public",
74
73
  },
75
74
  fakeMeta,
76
75
  )) as {
@@ -97,6 +96,24 @@ describe("create_page MCP tool", () => {
97
96
  const row = getPageBySlug(agentId, "hello-page");
98
97
  expect(row).not.toBeNull();
99
98
  expect(row!.body).toBe("<h1>hello</h1>");
99
+ expect(row!.authMode).toBe("authed");
100
+ });
101
+
102
+ test("explicit public auth mode is preserved", async () => {
103
+ const tool = buildServer();
104
+ await tool.handler(
105
+ {
106
+ title: "Public Page",
107
+ body: "<h1>public</h1>",
108
+ contentType: "text/html",
109
+ authMode: "public",
110
+ },
111
+ fakeMeta,
112
+ );
113
+
114
+ const row = getPageBySlug(agentId, "public-page");
115
+ expect(row).not.toBeNull();
116
+ expect(row!.authMode).toBe("public");
100
117
  });
101
118
 
102
119
  test("re-running with the same slug upserts + bumps edit-counter", async () => {
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DbQueryInputSchema, resolveDbQuerySql } from "../http/db-query";
3
+
4
+ describe("db-query input compatibility", () => {
5
+ test("canonical sql input resolves to sql", () => {
6
+ const parsed = DbQueryInputSchema.parse({ sql: "SELECT 1", params: [] });
7
+
8
+ expect(resolveDbQuerySql(parsed)).toBe("SELECT 1");
9
+ });
10
+
11
+ test("legacy query input remains a runtime alias", () => {
12
+ const parsed = DbQueryInputSchema.parse({ query: "SELECT 2" });
13
+
14
+ expect(resolveDbQuerySql(parsed)).toBe("SELECT 2");
15
+ });
16
+
17
+ test("sql takes precedence when both sql and query are present", () => {
18
+ const parsed = DbQueryInputSchema.parse({ sql: "SELECT 3", query: "SELECT 4" });
19
+
20
+ expect(resolveDbQuerySql(parsed)).toBe("SELECT 3");
21
+ });
22
+
23
+ test("rejects input without sql or query", () => {
24
+ const parsed = DbQueryInputSchema.safeParse({});
25
+
26
+ expect(parsed.success).toBe(false);
27
+ });
28
+ });
@@ -1,8 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS,
4
+ isCodexCreditsExhaustedMessage,
5
+ isRateLimitMessage,
6
+ MAX_RATE_LIMIT_RESET_MS,
7
+ MIN_CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS,
3
8
  parseCodexRateLimitResetTime,
4
9
  parseRateLimitResetTime,
5
10
  parseStderrForErrors,
11
+ resolveCodexCreditsExhaustedCooldownMs,
6
12
  SessionErrorTracker,
7
13
  trackErrorFromJson,
8
14
  } from "../utils/error-tracker";
@@ -567,6 +573,121 @@ describe("parseRateLimitResetTime", () => {
567
573
  });
568
574
  });
569
575
 
576
+ describe("isCodexCreditsExhaustedMessage", () => {
577
+ const CANONICAL =
578
+ "Your workspace is out of credits. Ask your workspace owner to refill in order to continue.";
579
+
580
+ test("returns true for the canonical credits-exhausted message", () => {
581
+ expect(isCodexCreditsExhaustedMessage(CANONICAL)).toBe(true);
582
+ });
583
+
584
+ test("matches 'out of credits' fragment", () => {
585
+ expect(isCodexCreditsExhaustedMessage("Your workspace is out of credits.")).toBe(true);
586
+ });
587
+
588
+ test("matches 'refill in order to continue' fragment", () => {
589
+ expect(isCodexCreditsExhaustedMessage("Please refill in order to continue.")).toBe(true);
590
+ });
591
+
592
+ test("matches 'workspace owner to refill' fragment", () => {
593
+ expect(isCodexCreditsExhaustedMessage("Ask your workspace owner to refill credits.")).toBe(
594
+ true,
595
+ );
596
+ });
597
+
598
+ test("is case-insensitive", () => {
599
+ expect(isCodexCreditsExhaustedMessage("OUT OF CREDITS")).toBe(true);
600
+ });
601
+
602
+ test("returns false for unrelated errors", () => {
603
+ expect(isCodexCreditsExhaustedMessage("No conversation found with session ID abc123")).toBe(
604
+ false,
605
+ );
606
+ expect(isCodexCreditsExhaustedMessage("Authentication failed")).toBe(false);
607
+ expect(isCodexCreditsExhaustedMessage("Rate limit exceeded")).toBe(false);
608
+ expect(isCodexCreditsExhaustedMessage("Connection timeout")).toBe(false);
609
+ });
610
+
611
+ test("returns false for bare 'refill' without qualifying context", () => {
612
+ expect(isCodexCreditsExhaustedMessage("Please refill your coffee")).toBe(false);
613
+ });
614
+ });
615
+
616
+ describe("isRateLimitMessage — Codex credits-exhausted integration", () => {
617
+ const CANONICAL =
618
+ "Your workspace is out of credits. Ask your workspace owner to refill in order to continue.";
619
+
620
+ test("returns true for canonical Codex credits-exhausted message", () => {
621
+ expect(isRateLimitMessage(CANONICAL)).toBe(true);
622
+ });
623
+
624
+ test("still returns true for standard rate-limit messages", () => {
625
+ expect(isRateLimitMessage("Rate limit exceeded")).toBe(true);
626
+ expect(isRateLimitMessage("429 Too Many Requests")).toBe(true);
627
+ expect(isRateLimitMessage("You've hit your weekly limit")).toBe(true);
628
+ });
629
+
630
+ test("still returns false for unrelated errors", () => {
631
+ expect(isRateLimitMessage("No conversation found with session ID abc123")).toBe(false);
632
+ expect(isRateLimitMessage("Authentication failed")).toBe(false);
633
+ expect(isRateLimitMessage("Server error 500")).toBe(false);
634
+ });
635
+ });
636
+
637
+ describe("resolveCodexCreditsExhaustedCooldownMs", () => {
638
+ test("absent (undefined) → default constant", () => {
639
+ expect(resolveCodexCreditsExhaustedCooldownMs(undefined)).toBe(
640
+ CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS,
641
+ );
642
+ });
643
+
644
+ test("null → default constant", () => {
645
+ expect(resolveCodexCreditsExhaustedCooldownMs(null)).toBe(CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS);
646
+ });
647
+
648
+ test("empty string → default constant", () => {
649
+ expect(resolveCodexCreditsExhaustedCooldownMs("")).toBe(CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS);
650
+ });
651
+
652
+ test("non-numeric string → default constant", () => {
653
+ expect(resolveCodexCreditsExhaustedCooldownMs("abc")).toBe(CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS);
654
+ });
655
+
656
+ test("zero → default constant", () => {
657
+ expect(resolveCodexCreditsExhaustedCooldownMs("0")).toBe(CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS);
658
+ });
659
+
660
+ test("negative → default constant", () => {
661
+ expect(resolveCodexCreditsExhaustedCooldownMs("-5")).toBe(CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS);
662
+ });
663
+
664
+ test("valid in-range string (30m) → parsed value", () => {
665
+ expect(resolveCodexCreditsExhaustedCooldownMs("1800000")).toBe(1_800_000);
666
+ });
667
+
668
+ test("valid in-range number (30m) → parsed value", () => {
669
+ expect(resolveCodexCreditsExhaustedCooldownMs(1_800_000)).toBe(1_800_000);
670
+ });
671
+
672
+ test("below floor → clamped to MIN", () => {
673
+ expect(resolveCodexCreditsExhaustedCooldownMs("1000")).toBe(
674
+ MIN_CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS,
675
+ );
676
+ });
677
+
678
+ test("above ceiling (8d) → clamped to MAX", () => {
679
+ expect(resolveCodexCreditsExhaustedCooldownMs(String(8 * 24 * 60 * 60 * 1000))).toBe(
680
+ MAX_RATE_LIMIT_RESET_MS,
681
+ );
682
+ });
683
+
684
+ test("partial-numeric strings → default constant (not silently truncated)", () => {
685
+ for (const bad of ["60000ms", "1.5", "123abc", "1e5"]) {
686
+ expect(resolveCodexCreditsExhaustedCooldownMs(bad)).toBe(CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS);
687
+ }
688
+ });
689
+ });
690
+
570
691
  describe("Codex/Claude coexistence — single tracker handles both providers", () => {
571
692
  test("processRateLimitEvent (Claude) and processCodexUsageLimitMessage (Codex) both stash into getRateLimitResetAt", () => {
572
693
  // Claude path: processRateLimitEvent
@@ -138,6 +138,39 @@ describe("validateConfigValue", () => {
138
138
  expect(validateConfigValue("HARNESS_PROVIDER", 42)).not.toBeNull();
139
139
  expect(validateConfigValue("HARNESS_PROVIDER", null)).not.toBeNull();
140
140
  });
141
+
142
+ test("accepts a valid CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS", () => {
143
+ expect(validateConfigValue("CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS", "7200000")).toBeNull();
144
+ expect(validateConfigValue("CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS", "1800000")).toBeNull();
145
+ // case-insensitive key lookup
146
+ expect(validateConfigValue("codex_credits_exhausted_cooldown_ms", "60000")).toBeNull();
147
+ });
148
+
149
+ test("rejects non-positive / non-numeric CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS", () => {
150
+ for (const bad of ["abc", "0", "-5", ""]) {
151
+ const err = validateConfigValue("CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS", bad);
152
+ expect(err).not.toBeNull();
153
+ expect(err).toMatch(/CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS/);
154
+ }
155
+ });
156
+
157
+ test("rejects partial-numeric CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS values", () => {
158
+ for (const bad of ["60000ms", "1.5", "123abc", "1e5", " 60000 ms"]) {
159
+ const err = validateConfigValue("CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS", bad);
160
+ expect(err).not.toBeNull();
161
+ expect(err).toMatch(/CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS/);
162
+ }
163
+ });
164
+
165
+ test("accepts only boolean-like SWARM_USE_CLAUDE_BRIDGE values", () => {
166
+ for (const value of ["true", "false", "1", "0", " TRUE "]) {
167
+ expect(validateConfigValue("SWARM_USE_CLAUDE_BRIDGE", value)).toBeNull();
168
+ }
169
+ expect(validateConfigValue("SWARM_USE_CLAUDE_BRIDGE", "yes")).toMatch(
170
+ /SWARM_USE_CLAUDE_BRIDGE/,
171
+ );
172
+ expect(validateConfigValue("SWARM_USE_CLAUDE_BRIDGE", true)).toMatch(/SWARM_USE_CLAUDE_BRIDGE/);
173
+ });
141
174
  });
142
175
 
143
176
  // ─── getResolvedConfig — scope precedence for HARNESS_PROVIDER ───────────────
@@ -271,6 +271,26 @@ describe("Heartbeat Checklist", () => {
271
271
  expect(tasks[0]!.task).toContain("Review blocked tasks");
272
272
  });
273
273
 
274
+ test("created task enforces HEARTBEAT tracked-item cap and seeded audit call", async () => {
275
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
276
+ updateAgentProfile(lead.id, {
277
+ heartbeatMd: "- Watch PR #123 until 2026-06-07\n",
278
+ });
279
+
280
+ await checkHeartbeatChecklist();
281
+
282
+ const tasks = getDb()
283
+ .query("SELECT task FROM agent_tasks WHERE taskType = 'heartbeat-checklist'")
284
+ .all() as Array<{ task: string }>;
285
+ expect(tasks.length).toBe(1);
286
+ expect(tasks[0]!.task).toContain("Active Blockers + Watch Items + Open Discussion");
287
+ expect(tasks[0]!.task).toContain("≤10 items");
288
+ expect(tasks[0]!.task).toContain("20 is the absolute max");
289
+ expect(tasks[0]!.task).toContain("script-run");
290
+ expect(tasks[0]!.task).toContain("Heartbeat Audit");
291
+ expect(tasks[0]!.task).toContain("Rule #11");
292
+ });
293
+
274
294
  test("created task has correct tags", async () => {
275
295
  const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
276
296
  updateAgentProfile(lead.id, {
@@ -369,6 +389,22 @@ describe("Heartbeat Checklist", () => {
369
389
  expect(tasks[0]!.task).toContain("Check Slack for unaddressed requests");
370
390
  });
371
391
 
392
+ test("boot-triage task enforces HEARTBEAT cap and seeded boot-triage call", async () => {
393
+ createAgent({ name: "lead", isLead: true, status: "idle" });
394
+
395
+ await createBootTriageTask();
396
+
397
+ const tasks = getDb()
398
+ .query("SELECT task FROM agent_tasks WHERE taskType = 'boot-triage'")
399
+ .all() as Array<{ task: string }>;
400
+ expect(tasks[0]!.task).toContain("Active Blockers + Watch Items + Open Discussion");
401
+ expect(tasks[0]!.task).toContain("≤10 items");
402
+ expect(tasks[0]!.task).toContain("20 is the absolute max");
403
+ expect(tasks[0]!.task).toContain("script-run");
404
+ expect(tasks[0]!.task).toContain("boot-triage");
405
+ expect(tasks[0]!.task).toContain("one read-only call");
406
+ });
407
+
372
408
  test("dedup: skips when active boot-triage task exists", async () => {
373
409
  const lead = createAgent({ name: "lead", isLead: true, status: "idle" });
374
410
  updateAgentProfile(lead.id, {
@@ -55,6 +55,12 @@ describe("script MCP tools", () => {
55
55
  "Remove a swarm-shared script from the catalog. Versions table preserves history.",
56
56
  "script-query-types":
57
57
  "Fetch the signature + the auto-generated `swarm-sdk.d.ts` (derived from the live MCP tool registry) + the `stdlib.d.ts` blobs — for IDE-style introspection before authoring or running a script. The same types are used by `script-upsert`'s typecheck pass, so they are authoritative.",
58
+ "launch-script-run":
59
+ "Launch a durable one-off script workflow run. The run executes in the background and can be inspected with get-script-run for terminal status and journal entries.",
60
+ "get-script-run":
61
+ "Get a durable script workflow run by ID, including its journal entries for swarm-script, raw-llm, and agent-task steps.",
62
+ "list-script-runs":
63
+ "List durable script workflow runs, optionally filtered by status or agent ID.",
58
64
  };
59
65
 
60
66
  for (const [name, description] of Object.entries(expected)) {
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { closeIdleMcpTransports } from "../http/mcp";
4
+ import { closeIdleMcpUserTransports } from "../http/mcp-user";
5
+
6
+ function fakeTransport(onClose: () => void): StreamableHTTPServerTransport {
7
+ return {
8
+ close: onClose,
9
+ } as StreamableHTTPServerTransport;
10
+ }
11
+
12
+ describe("MCP transport idle GC", () => {
13
+ test("closes and deletes stale owner transports", () => {
14
+ const closed: string[] = [];
15
+ const transports: Record<string, StreamableHTTPServerTransport> = {
16
+ fresh: fakeTransport(() => closed.push("fresh")),
17
+ stale: fakeTransport(() => closed.push("stale")),
18
+ unknown: fakeTransport(() => closed.push("unknown")),
19
+ };
20
+ const activity = {
21
+ fresh: 9_500,
22
+ stale: 1_000,
23
+ };
24
+
25
+ const removed = closeIdleMcpTransports(transports, activity, {
26
+ now: 10_000,
27
+ idleTimeoutMs: 1_000,
28
+ });
29
+
30
+ expect(removed).toBe(1);
31
+ expect(closed).toEqual(["stale"]);
32
+ expect(transports.stale).toBeUndefined();
33
+ expect(activity.stale).toBeUndefined();
34
+ expect(transports.fresh).toBeDefined();
35
+ expect(transports.unknown).toBeDefined();
36
+ expect(activity.unknown).toBe(10_000);
37
+ });
38
+
39
+ test("deletes user ownership metadata for stale user transports", () => {
40
+ const closed: string[] = [];
41
+ const transports: Record<string, StreamableHTTPServerTransport> = {
42
+ stale: fakeTransport(() => closed.push("stale")),
43
+ };
44
+ const sessionUsers = { stale: "user_1" };
45
+ const activity = { stale: 1_000 };
46
+
47
+ const removed = closeIdleMcpUserTransports(transports, sessionUsers, activity, {
48
+ now: 10_000,
49
+ idleTimeoutMs: 1_000,
50
+ });
51
+
52
+ expect(removed).toBe(1);
53
+ expect(closed).toEqual(["stale"]);
54
+ expect(transports.stale).toBeUndefined();
55
+ expect(sessionUsers.stale).toBeUndefined();
56
+ expect(activity.stale).toBeUndefined();
57
+ });
58
+ });
@@ -0,0 +1,78 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { closeDb, initDb } from "../be/db";
5
+ import { EMBEDDING_DIMENSIONS } from "../be/memory/constants";
6
+ import { handleMemory } from "../http/memory";
7
+ import { getPathSegments } from "../http/utils";
8
+
9
+ const TEST_DB_PATH = "./test-memory-health-endpoint.sqlite";
10
+
11
+ function fakeReqRes(path: string) {
12
+ const req = {
13
+ method: "GET",
14
+ url: path,
15
+ headers: {},
16
+ } as unknown as IncomingMessage;
17
+
18
+ const captured = { status: 0, body: "" };
19
+ const res = {
20
+ writeHead(status: number) {
21
+ captured.status = status;
22
+ return this;
23
+ },
24
+ end(chunk?: string) {
25
+ if (chunk) captured.body = chunk;
26
+ return this;
27
+ },
28
+ } as unknown as ServerResponse;
29
+
30
+ return { req, res, captured };
31
+ }
32
+
33
+ describe("GET /api/memory/health", () => {
34
+ beforeAll(async () => {
35
+ for (const suffix of ["", "-wal", "-shm"]) {
36
+ try {
37
+ await unlink(TEST_DB_PATH + suffix);
38
+ } catch {}
39
+ }
40
+ initDb(TEST_DB_PATH);
41
+ });
42
+
43
+ afterAll(async () => {
44
+ closeDb();
45
+ for (const suffix of ["", "-wal", "-shm"]) {
46
+ try {
47
+ await unlink(TEST_DB_PATH + suffix);
48
+ } catch {}
49
+ }
50
+ });
51
+
52
+ test("returns vector index health JSON", async () => {
53
+ const { req, res, captured } = fakeReqRes("/api/memory/health");
54
+ const handled = await handleMemory(req, res, getPathSegments(req.url || ""), undefined);
55
+
56
+ expect(handled).toBe(true);
57
+ expect(captured.status).toBe(200);
58
+
59
+ const body = JSON.parse(captured.body);
60
+ expect(body).toMatchObject({
61
+ sqliteVec: {
62
+ extensionLoaded: expect.any(Boolean),
63
+ tableExists: expect.any(Boolean),
64
+ initialized: expect.any(Boolean),
65
+ vectorDimensions: EMBEDDING_DIMENSIONS,
66
+ distanceMetric: "cosine",
67
+ },
68
+ counts: {
69
+ total: expect.any(Number),
70
+ withEmbedding: expect.any(Number),
71
+ searchable: expect.any(Number),
72
+ memoryVec: expect.any(Number),
73
+ },
74
+ retrievalMode: expect.any(String),
75
+ reasons: expect.any(Array),
76
+ });
77
+ });
78
+ });