@desplega.ai/agent-swarm 2.4.4 → 2.4.5

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 (221) hide show
  1. package/dist/{acp-adapter-thq04adh.js → acp-adapter-2wdkktv6.js} +1 -1
  2. package/dist/{actions-j6ywh6x5.js → actions-c40ebcm4.js} +18 -18
  3. package/dist/{amp-adapter-k8b5xmj8.js → amp-adapter-wb67m95z.js} +2 -2
  4. package/dist/{app-rb7vc02s.js → app-353vre8g.js} +3 -3
  5. package/dist/{assistant-ftta1zjf.js → assistant-sj1njh23.js} +21 -21
  6. package/dist/{boot-reembed-srp6v6af.js → boot-reembed-b9c3az3c.js} +1 -1
  7. package/dist/{boot-reembed-xns7h5h9.js → boot-reembed-qdkvxeem.js} +1 -1
  8. package/dist/{boot-scrub-sweep-3qqrh2mz.js → boot-scrub-sweep-m3wh8eks.js} +2 -2
  9. package/dist/{boot-seeds-9ks8hce7.js → boot-seeds-j2273s54.js} +4 -4
  10. package/dist/{claude-adapter-k5ksyres.js → claude-adapter-m7q0a085.js} +2 -2
  11. package/dist/{claude-managed-adapter-h2y889ge.js → claude-managed-adapter-5ptb6tzs.js} +2 -2
  12. package/dist/{claude-managed-setup-n5a5eqks.js → claude-managed-setup-smrqk5j5.js} +3 -3
  13. package/dist/{claude-sdk-session-2j3j4kwn.js → claude-sdk-session-38xnz21e.js} +8284 -8267
  14. package/dist/{cli-cynvc9fj.js → cli-018r4jq6.js} +5 -5
  15. package/dist/{cli-9fhkkpj5.js → cli-1f3qzw6y.js} +1 -1
  16. package/dist/{cli-dqb4n629.js → cli-1sp861s4.js} +7 -7
  17. package/dist/{cli-gqvh8d8a.js → cli-1syn2xy1.js} +10 -10
  18. package/dist/{cli-qkhc5aqy.js → cli-37j04k05.js} +11 -11
  19. package/dist/{cli-1d70crsy.js → cli-3a45zcgm.js} +1 -1
  20. package/dist/{cli-t7cyef5g.js → cli-43x64hv3.js} +4 -4
  21. package/dist/{cli-hj3debgw.js → cli-4zfqgjc5.js} +2 -2
  22. package/dist/{cli-55qmbp6z.js → cli-7c9qs530.js} +1 -1
  23. package/dist/{cli-9qtjfd60.js → cli-848hxdwj.js} +1 -1
  24. package/dist/{cli-pccfmtb5.js → cli-94gwyg7m.js} +7 -7
  25. package/dist/{cli-51y09c6p.js → cli-9hstj09h.js} +5 -5
  26. package/dist/{cli-zrwpctp4.js → cli-9yqgm9nd.js} +22 -22
  27. package/dist/{cli-esxerzg7.js → cli-a92r1127.js} +1 -1
  28. package/dist/{cli-b8k7epj1.js → cli-be2161h1.js} +7 -7
  29. package/dist/{cli-xc27fwzg.js → cli-bnes4z6b.js} +2 -2
  30. package/dist/{cli-zrk6jxv8.js → cli-cxxe61f6.js} +1 -1
  31. package/dist/{cli-amyhm3gj.js → cli-dk44dxec.js} +1 -1
  32. package/dist/{cli-nc35pxb7.js → cli-eww120g8.js} +1 -0
  33. package/dist/{cli-a0kcty51.js → cli-f01h25yt.js} +1 -1
  34. package/dist/{cli-82d3nkc3.js → cli-f0g3pgvf.js} +1 -1
  35. package/dist/{cli-1ephgwnq.js → cli-ftc1g5tj.js} +2 -2
  36. package/dist/{cli-2v045xyf.js → cli-fv5hepyy.js} +3 -3
  37. package/dist/{cli-bnz0r3kc.js → cli-g35m2xwm.js} +4 -4
  38. package/dist/{cli-qje9k2nh.js → cli-g9ypd5en.js} +12 -2
  39. package/dist/{cli-w6nadkh4.js → cli-j99azg3g.js} +3 -3
  40. package/dist/{cli-er83cjbn.js → cli-k8g16gz3.js} +416 -116
  41. package/dist/{cli-4dg55wsk.js → cli-kpw04va0.js} +1 -1
  42. package/dist/{cli-3cgngyd8.js → cli-ms85309d.js} +33 -15
  43. package/dist/{cli-v3yr2gbg.js → cli-n72rg53p.js} +1471 -635
  44. package/dist/{cli-37yb7307.js → cli-p14m2cnp.js} +1 -1
  45. package/dist/{cli-2zqpkvtt.js → cli-qfxc84gh.js} +1 -1
  46. package/dist/{cli-44159k28.js → cli-qwhy2qma.js} +1 -1
  47. package/dist/{cli-2y91wnbh.js → cli-st8aw1m6.js} +221 -9
  48. package/dist/{cli-vq97g1jq.js → cli-tw4p90jb.js} +92 -192
  49. package/dist/{cli-te6b5wvg.js → cli-v0yrfmdh.js} +1 -1
  50. package/dist/{cli-xj0re5tm.js → cli-v8fjakwf.js} +1 -1
  51. package/dist/{cli-p20r8w42.js → cli-wt0jf9hm.js} +215 -80
  52. package/dist/{cli-q6r7t299.js → cli-xpa64rek.js} +33 -11
  53. package/dist/{cli-vdm20wej.js → cli-z7d29rw9.js} +2 -2
  54. package/dist/cli.js +19 -19
  55. package/dist/{codex-adapter-968xwfy7.js → codex-adapter-tn6k5mkg.js} +1 -1
  56. package/dist/{codex-session-runner-ht4z99fa.js → codex-session-runner-7vjt7jbw.js} +1 -1
  57. package/dist/{commands-pcymaeg9.js → commands-t7rz9146.js} +2 -2
  58. package/dist/{controller-fpjyk46r.js → controller-m2jasma5.js} +2 -2
  59. package/dist/{controllers-connect-mwzx1fh6.js → controllers-connect-9ftpex99.js} +19 -19
  60. package/dist/{cursor-adapter-cp4s2e2f.js → cursor-adapter-mjv79e4z.js} +1 -1
  61. package/dist/{db-xsjg9jqq.js → db-6sfrn9g3.js} +1 -1
  62. package/dist/{db-sgd12bt9.js → db-rn673r06.js} +3 -3
  63. package/dist/{dispatcher-x0jfw2c9.js → dispatcher-c155xz0p.js} +17 -17
  64. package/dist/{dsh-adapter-jrq7ma7h.js → dsh-adapter-3mmszc1x.js} +1 -1
  65. package/dist/{external-channel-8njj4h3b.js → external-channel-35k6tkv8.js} +2 -2
  66. package/dist/{grok-adapter-kkr97v2f.js → grok-adapter-tfrq7tg1.js} +2 -2
  67. package/dist/handlers-7gw64dxw.js +39 -0
  68. package/dist/{heartbeat-wd03zhn0.js → heartbeat-dyg4npkp.js} +20 -20
  69. package/dist/{hook-f0vghj42.js → hook-drspbwpe.js} +1 -1
  70. package/dist/{hook-xzj2ts2p.js → hook-x26b31bg.js} +1 -1
  71. package/dist/{http-4hwxv62p.js → http-pk972xyw.js} +355 -216
  72. package/dist/{index-ww2vznkp.js → index-arytgj5q.js} +1 -1
  73. package/dist/{index-hg0x2cdv.js → index-bgdyvcnv.js} +17 -17
  74. package/dist/{index-c4ykd0f1.js → index-dr94zbnh.js} +13348 -11611
  75. package/dist/{index-gdh8jrdm.js → index-gabbcdn2.js} +17 -17
  76. package/dist/{index-97q4xn03.js → index-jwxkkex2.js} +129 -75
  77. package/dist/index-tfb26zhy.js +41 -0
  78. package/dist/{keepalive-hrh7xnr9.js → keepalive-7yrng7px.js} +3 -3
  79. package/dist/{lead-ee8vazz2.js → lead-7e02z839.js} +29 -29
  80. package/dist/{maintenance-e8wnwnqx.js → maintenance-e6cemdqt.js} +1 -1
  81. package/dist/{node-3v3f08f7.js → node-de6akczr.js} +10 -9
  82. package/dist/{oauth-refresh-sweep-zm0yj4sj.js → oauth-refresh-sweep-yw3xdbcj.js} +2 -2
  83. package/dist/{onboard-dtwcq603.js → onboard-0a9y5yrc.js} +2 -2
  84. package/dist/{otel-impl-w3fbhpn0.js → otel-impl-trjpkfb7.js} +1 -1
  85. package/dist/{pricing-refresh-j38dehfs.js → pricing-refresh-d2b5my0y.js} +3 -3
  86. package/dist/{rbac-roles-ejxx9b35.js → rbac-roles-hey3yv9d.js} +2 -2
  87. package/dist/{rbac-roles-ae8yttq4.js → rbac-roles-nxh5ahwk.js} +1 -1
  88. package/dist/{registry-rxw10xzg.js → registry-ajhexe44.js} +1 -1
  89. package/dist/{render-v2-n16n0phe.js → render-v2-rr3p2atg.js} +3 -3
  90. package/dist/{resume-z0fgjc38.js → resume-2efkf1s1.js} +5 -5
  91. package/dist/{session-access-cv3mq6mc.js → session-access-s2mttxpw.js} +4 -4
  92. package/dist/{setup-mafhj3yt.js → setup-vypma5yt.js} +1 -1
  93. package/dist/{work-objects-8b86mgjp.js → work-objects-rvhckdkp.js} +20 -20
  94. package/dist/{worker-16s75hm5.js → worker-5snytpbd.js} +29 -29
  95. package/openapi.json +9 -2
  96. package/package.json +7 -7
  97. package/src/apps/catalog.generated.json +206 -4
  98. package/src/apps/page-validator.ts +13 -1
  99. package/src/be/chunking.ts +42 -9
  100. package/src/be/db/tasks/draft-text.ts +15 -0
  101. package/src/be/db/tasks/read.ts +14 -1
  102. package/src/be/db.ts +5 -1
  103. package/src/be/memory/index-content.ts +36 -17
  104. package/src/be/memory/providers/openai-embedding.ts +115 -61
  105. package/src/be/memory/providers/sqlite-store.ts +69 -23
  106. package/src/be/memory/reranker.ts +31 -1
  107. package/src/be/memory/search-candidates.ts +47 -0
  108. package/src/be/memory/types.ts +14 -0
  109. package/src/be/scripts/typecheck.ts +2 -0
  110. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +106 -54
  111. package/src/be/seed-scripts/index.ts +2 -2
  112. package/src/be/swarm-config-guard.ts +24 -1
  113. package/src/be/task-attachment-store.ts +30 -0
  114. package/src/extensions/catalog.generated.json +4 -4
  115. package/src/extensions/contract-types.generated.ts +1 -1
  116. package/src/extensions/contract.ts +7 -0
  117. package/src/fs/provider.ts +13 -1
  118. package/src/heartbeat/heartbeat.ts +31 -11
  119. package/src/http/fs.ts +54 -34
  120. package/src/http/mcp-body.ts +37 -0
  121. package/src/http/mcp-user.ts +3 -5
  122. package/src/http/mcp.ts +6 -7
  123. package/src/http/memory.ts +26 -15
  124. package/src/http/page-proxy.ts +61 -5
  125. package/src/http/pages-public.ts +27 -44
  126. package/src/http/pages.ts +4 -4
  127. package/src/http/script-runs.ts +2 -0
  128. package/src/http/users.ts +12 -1
  129. package/src/integrations/chat-sdk/adapters.ts +7 -3
  130. package/src/integrations/chat-sdk/attachments.ts +90 -12
  131. package/src/integrations/chat-sdk/facts.ts +120 -0
  132. package/src/integrations/chat-sdk/inbound.ts +156 -8
  133. package/src/integrations/chat-sdk/limits.ts +24 -0
  134. package/src/integrations/chat-sdk/outbound-files.ts +186 -0
  135. package/src/integrations/chat-sdk/outbound.ts +234 -4
  136. package/src/integrations/chat-sdk/relay.ts +4 -1
  137. package/src/integrations/chat-sdk/router.ts +28 -2
  138. package/src/integrations/chat-sdk/steering.ts +9 -0
  139. package/src/integrations/chat-sdk/templates.ts +14 -0
  140. package/src/integrations/chat-sdk/thread-buffer.ts +4 -0
  141. package/src/mcp-limits.ts +20 -0
  142. package/src/pages/browser-sdk.ts +8 -3
  143. package/src/pages/feedback-overlay.ts +1 -1
  144. package/src/providers/amp-adapter.ts +1 -1
  145. package/src/providers/codex-adapter.ts +106 -119
  146. package/src/providers/codex-agents-md.ts +35 -229
  147. package/src/providers/cursor-adapter.ts +1 -1
  148. package/src/realtime/transport.ts +1 -2
  149. package/src/script-workflows/workflow-ctx.ts +1 -0
  150. package/src/scripts-runtime/types/stdlib.d.ts +2 -0
  151. package/src/scripts-runtime/types/swarm-extension.d.ts +8 -0
  152. package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -0
  153. package/src/server-user.ts +2 -0
  154. package/src/server.ts +2 -0
  155. package/src/slack/assistant.ts +2 -2
  156. package/src/slack/files.ts +2 -0
  157. package/src/slack/handlers.ts +4 -4
  158. package/src/slack/inbound-files.ts +39 -6
  159. package/src/slack/thread-buffer.ts +2 -1
  160. package/src/tasks/task-terminal-effects.ts +21 -17
  161. package/src/tests/apps-card-grid.test.ts +197 -0
  162. package/src/tests/apps-catalog-additions.test.ts +120 -0
  163. package/src/tests/apps-tree.test.ts +129 -0
  164. package/src/tests/chat-sdk-attachments-reply.test.ts +52 -0
  165. package/src/tests/chat-sdk-config.test.ts +11 -0
  166. package/src/tests/chat-sdk-facts.test.ts +23 -0
  167. package/src/tests/chat-sdk-inbound.test.ts +558 -4
  168. package/src/tests/chat-sdk-outbound-files.test.ts +652 -0
  169. package/src/tests/codex-adapter.test.ts +167 -242
  170. package/src/tests/config-api-only-keys.test.ts +9 -0
  171. package/src/tests/cors-allowlist.test.ts +1 -1
  172. package/src/tests/extensions-example-slack-reply-gate.test.ts +262 -91
  173. package/src/tests/heartbeat-checklist.test.ts +33 -0
  174. package/src/tests/heartbeat-jev-gate-credentials.test.ts +44 -0
  175. package/src/tests/heartbeat-jev-gate.test.ts +242 -1
  176. package/src/tests/kv-page-proxy.test.ts +91 -5
  177. package/src/tests/launch-password-rejection.test.ts +2 -2
  178. package/src/tests/mcp-input-caps.test.ts +294 -0
  179. package/src/tests/memory-chunked-search-starvation.test.ts +200 -0
  180. package/src/tests/memory-embedding-input-cap.test.ts +114 -0
  181. package/src/tests/memory.test.ts +14 -0
  182. package/src/tests/page-guest-session.test.ts +19 -8
  183. package/src/tests/page-proxy-authed.test.ts +1 -1
  184. package/src/tests/page-proxy.test.ts +17 -10
  185. package/src/tests/page-session.test.ts +102 -1
  186. package/src/tests/pages-authed-mode.test.ts +82 -12
  187. package/src/tests/pages-password-mode.test.ts +5 -5
  188. package/src/tests/realtime-transport.test.ts +13 -7
  189. package/src/tests/script-runs-http.test.ts +84 -0
  190. package/src/tests/scripts-http.test.ts +10 -1
  191. package/src/tests/swarm-config-export-to-env.test.ts +22 -3
  192. package/src/tests/task-audio-transcription.test.ts +299 -0
  193. package/src/tests/task-completion-memory-chunking.test.ts +434 -0
  194. package/src/tests/task-terminal-secret-scrub.test.ts +6 -1
  195. package/src/tests/transcription.test.ts +299 -0
  196. package/src/tests/workflow-system-one-decision-providers.test.ts +98 -1
  197. package/src/tests/workflow-system-one-decision.test.ts +2 -1
  198. package/src/tools/chat-access.ts +10 -0
  199. package/src/tools/chat-post.ts +31 -11
  200. package/src/tools/chat-reply.ts +32 -10
  201. package/src/tools/memory-search.ts +3 -1
  202. package/src/tools/system-one-decide.ts +17 -4
  203. package/src/types.ts +6 -0
  204. package/src/utils/config-env-export.ts +3 -1
  205. package/src/utils/page-session.ts +133 -20
  206. package/src/utils/transcription.ts +246 -0
  207. package/src/workflows/executors/system-one-decision.ts +28 -7
  208. package/src/workflows/executors/system-one-providers.ts +39 -4
  209. package/templates/extensions/slack-reply-gate/README.md +24 -22
  210. package/templates/extensions/slack-reply-gate/hooks.ts +146 -95
  211. package/templates/extensions/slack-reply-gate/manifest.json +2 -2
  212. package/templates/skills/apps/SKILL.md +28 -6
  213. package/templates/skills/apps/content.md +28 -6
  214. package/templates/skills/chat-sdk/content.md +6 -2
  215. package/templates/skills/code-quality/content.md +2 -1
  216. package/templates/skills/heartbeat-runbook/content.md +5 -5
  217. package/templates/skills/pages/content.md +6 -4
  218. package/templates/skills/swarm-scripts/SKILL.md +2 -2
  219. package/templates/skills/swarm-scripts/content.md +2 -2
  220. package/dist/handlers-0pvee9nw.js +0 -39
  221. package/dist/index-thm0y6a8.js +0 -41
@@ -0,0 +1,294 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ createServer as createHttpServer,
5
+ request as httpRequest,
6
+ type IncomingMessage,
7
+ type Server,
8
+ type ServerResponse,
9
+ } from "node:http";
10
+ import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
11
+ import { closeDb, createAgent, createUser, getDbClient, initDb } from "../be/db";
12
+ import { type IdentityActor, mintToken } from "../be/users";
13
+ import { handleCore } from "../http/core";
14
+ import { handleMcp } from "../http/mcp";
15
+ import { handleMcpUser } from "../http/mcp-user";
16
+ import { MCP_MAX_REQUEST_BODY_BYTES, MCP_MAX_TOOL_INPUT_ELEMENTS } from "../mcp-limits";
17
+ import { listenOnFreePort } from "./test-net";
18
+
19
+ // GHSA-46jr-4wx8-v453: both MCP surfaces must bound tools/call argument size
20
+ // and the raw HTTP body they read themselves.
21
+
22
+ const TEST_DB_PATH = "./test-mcp-input-caps.sqlite";
23
+ const API_KEY = "example-test-mcp-caps-key";
24
+ const ACTOR: IdentityActor = { kind: "operator", id: "test" };
25
+ const TOO_LARGE_MESSAGE = `Payload Too Large: Request body must not exceed ${MCP_MAX_REQUEST_BODY_BYTES} bytes`;
26
+
27
+ async function removeDbFiles(path: string): Promise<void> {
28
+ for (const suffix of ["", "-wal", "-shm"]) {
29
+ try {
30
+ await unlink(path + suffix);
31
+ } catch (error) {
32
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
33
+ }
34
+ }
35
+ }
36
+
37
+ let server: Server;
38
+ let port: number;
39
+
40
+ beforeAll(async () => {
41
+ await removeDbFiles(TEST_DB_PATH);
42
+ initDb(TEST_DB_PATH);
43
+ const transports: Record<string, StreamableHTTPServerTransport> = {};
44
+ const transportsUser: Record<string, StreamableHTTPServerTransport> = {};
45
+ const sessionAgents: Record<string, string> = {};
46
+ const sessionUsers: Record<string, string> = {};
47
+ server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
48
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
49
+ if (await handleCore(req, res, myAgentId, API_KEY)) return;
50
+ if (await handleMcp(req, res, transports, {}, sessionAgents)) return;
51
+ if (await handleMcpUser(req, res, transportsUser, sessionUsers)) return;
52
+ res.writeHead(404);
53
+ res.end("Not Found");
54
+ });
55
+ port = await listenOnFreePort(server, "127.0.0.1");
56
+ });
57
+
58
+ afterAll(async () => {
59
+ await new Promise<void>((resolve) => server.close(() => resolve()));
60
+ closeDb();
61
+ await removeDbFiles(TEST_DB_PATH);
62
+ });
63
+
64
+ beforeEach(async () => {
65
+ const client = getDbClient();
66
+ await client.run("DELETE FROM user_tokens");
67
+ await client.run("DELETE FROM agent_tasks");
68
+ await client.run("DELETE FROM users");
69
+ await client.run("DELETE FROM agents");
70
+ });
71
+
72
+ type Surface = {
73
+ name: string;
74
+ path: "/mcp" | "/mcp-user";
75
+ /** Credentials headers for a fresh caller on this surface. */
76
+ headers: () => Promise<Record<string, string>>;
77
+ /** A tools/call that should succeed with the given extra argument members. */
78
+ call: (extra: Record<string, unknown>) => { name: string; arguments: Record<string, unknown> };
79
+ };
80
+
81
+ const surfaces: Surface[] = [
82
+ {
83
+ name: "agent MCP",
84
+ path: "/mcp",
85
+ headers: async () => {
86
+ const agent = await createAgent({ name: "Caps Agent", isLead: false, status: "idle" });
87
+ return { Authorization: `Bearer ${API_KEY}`, "X-Agent-ID": agent.id };
88
+ },
89
+ call: (extra) => ({ name: "get-swarm", arguments: { ...extra } }),
90
+ },
91
+ {
92
+ name: "user MCP",
93
+ path: "/mcp-user",
94
+ headers: async () => {
95
+ await createAgent({ name: "Caps Lead", isLead: true, status: "idle" });
96
+ const user = await createUser({ name: "Caps User" });
97
+ const token = (await mintToken(user.id, "caps", ACTOR)).plaintext;
98
+ return { Authorization: `Bearer ${token}` };
99
+ },
100
+ call: (extra) => ({ name: "send-task", arguments: { task: "caps check", ...extra } }),
101
+ },
102
+ ];
103
+
104
+ function parsePayload(text: string): unknown {
105
+ const trimmed = text.trim();
106
+ if (trimmed.startsWith("event:") || trimmed.startsWith("data:")) {
107
+ return JSON.parse(
108
+ trimmed
109
+ .split("\n")
110
+ .filter((line) => line.startsWith("data:"))
111
+ .map((line) => line.slice("data:".length).trim())
112
+ .join("\n"),
113
+ );
114
+ }
115
+ return JSON.parse(trimmed);
116
+ }
117
+
118
+ async function post(
119
+ path: string,
120
+ headers: Record<string, string>,
121
+ body: string,
122
+ sessionId?: string,
123
+ ): Promise<{ status: number; payload: unknown; sessionId: string | null }> {
124
+ const response = await fetch(`http://127.0.0.1:${port}${path}`, {
125
+ method: "POST",
126
+ headers: {
127
+ Accept: "application/json, text/event-stream",
128
+ "Content-Type": "application/json",
129
+ ...headers,
130
+ ...(sessionId ? { "mcp-session-id": sessionId } : {}),
131
+ },
132
+ body,
133
+ });
134
+ const text = await response.text();
135
+ return {
136
+ status: response.status,
137
+ payload: text ? parsePayload(text) : null,
138
+ sessionId: response.headers.get("mcp-session-id"),
139
+ };
140
+ }
141
+
142
+ async function openSession(path: string, headers: Record<string, string>): Promise<string> {
143
+ const init = await post(
144
+ path,
145
+ headers,
146
+ JSON.stringify({
147
+ jsonrpc: "2.0",
148
+ id: 1,
149
+ method: "initialize",
150
+ params: {
151
+ protocolVersion: "2024-11-05",
152
+ clientInfo: { name: "caps-test", version: "1" },
153
+ capabilities: {},
154
+ },
155
+ }),
156
+ );
157
+ expect(init.status).toBe(200);
158
+ if (!init.sessionId) throw new Error("initialize returned no mcp-session-id");
159
+ const ack = await post(
160
+ path,
161
+ headers,
162
+ JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
163
+ init.sessionId,
164
+ );
165
+ expect([200, 202]).toContain(ack.status);
166
+ return init.sessionId;
167
+ }
168
+
169
+ /** POST a body with no Content-Length (chunked), so only the streaming cap can stop it. */
170
+ function postChunked(
171
+ path: string,
172
+ headers: Record<string, string>,
173
+ totalBytes: number,
174
+ ): Promise<{ status: number; body: string }> {
175
+ return new Promise((resolve, reject) => {
176
+ const req = httpRequest(
177
+ {
178
+ host: "127.0.0.1",
179
+ port,
180
+ path,
181
+ method: "POST",
182
+ headers: { "Content-Type": "application/json", ...headers },
183
+ },
184
+ (res) => {
185
+ let body = "";
186
+ res.setEncoding("utf8");
187
+ res.on("data", (chunk: string) => {
188
+ body += chunk;
189
+ });
190
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
191
+ },
192
+ );
193
+ // The server may close the socket mid-upload after answering 413.
194
+ req.on("error", (error) => {
195
+ if (!req.writableFinished) return;
196
+ reject(error);
197
+ });
198
+ const chunk = Buffer.alloc(64 * 1024, 0x20);
199
+ let sent = 0;
200
+ const pump = () => {
201
+ while (sent < totalBytes) {
202
+ sent += chunk.byteLength;
203
+ if (!req.write(chunk)) {
204
+ req.once("drain", pump);
205
+ return;
206
+ }
207
+ }
208
+ req.end();
209
+ };
210
+ pump();
211
+ });
212
+ }
213
+
214
+ for (const surface of surfaces) {
215
+ describe(`${surface.name} input caps`, () => {
216
+ test("a normal tools/call passes", async () => {
217
+ const headers = await surface.headers();
218
+ const sessionId = await openSession(surface.path, headers);
219
+ const { status, payload } = await post(
220
+ surface.path,
221
+ headers,
222
+ JSON.stringify({
223
+ jsonrpc: "2.0",
224
+ id: 2,
225
+ method: "tools/call",
226
+ params: surface.call({}),
227
+ }),
228
+ sessionId,
229
+ );
230
+ expect(status).toBe(200);
231
+ const result = payload as { result?: { isError?: boolean } };
232
+ expect(result.result).toBeDefined();
233
+ expect(result.result?.isError).not.toBe(true);
234
+ });
235
+
236
+ test("a tools/call over the element limit is rejected before the handler runs", async () => {
237
+ const headers = await surface.headers();
238
+ const sessionId = await openSession(surface.path, headers);
239
+ const { status, payload } = await post(
240
+ surface.path,
241
+ headers,
242
+ JSON.stringify({
243
+ jsonrpc: "2.0",
244
+ id: 3,
245
+ method: "tools/call",
246
+ params: surface.call({ tags: new Array(MCP_MAX_TOOL_INPUT_ELEMENTS + 1).fill("t") }),
247
+ }),
248
+ sessionId,
249
+ );
250
+ expect(status).toBe(200);
251
+ const result = payload as {
252
+ result: { isError: boolean; content: Array<{ type: string; text: string }> };
253
+ };
254
+ expect(result.result.isError).toBe(true);
255
+ expect(result.result.content[0]?.text).toContain(
256
+ `more than the maximum of ${MCP_MAX_TOOL_INPUT_ELEMENTS} elements`,
257
+ );
258
+ const tasks = await getDbClient().query<{ id: string }>("SELECT id FROM agent_tasks");
259
+ expect(tasks).toHaveLength(0);
260
+ });
261
+
262
+ test("a body over the cap with Content-Length gets 413 and a JSON-RPC error", async () => {
263
+ const headers = await surface.headers();
264
+ const oversized = JSON.stringify({
265
+ jsonrpc: "2.0",
266
+ id: 4,
267
+ method: "tools/call",
268
+ params: surface.call({ blob: "x".repeat(MCP_MAX_REQUEST_BODY_BYTES) }),
269
+ });
270
+ const { status, payload } = await post(surface.path, headers, oversized);
271
+ expect(status).toBe(413);
272
+ expect(payload).toEqual({
273
+ jsonrpc: "2.0",
274
+ error: { code: -32000, message: TOO_LARGE_MESSAGE },
275
+ id: null,
276
+ });
277
+ });
278
+
279
+ test("a chunked body that crosses the cap gets 413", async () => {
280
+ const headers = await surface.headers();
281
+ const { status, body } = await postChunked(
282
+ surface.path,
283
+ headers,
284
+ MCP_MAX_REQUEST_BODY_BYTES + 128 * 1024,
285
+ );
286
+ expect(status).toBe(413);
287
+ expect(JSON.parse(body)).toEqual({
288
+ jsonrpc: "2.0",
289
+ error: { code: -32000, message: TOO_LARGE_MESSAGE },
290
+ id: null,
291
+ });
292
+ });
293
+ });
294
+ }
@@ -0,0 +1,200 @@
1
+ import { afterAll, afterEach, beforeAll, 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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { closeDb, createAgent, initDb } from "../be/db";
8
+ import { taskCompletionMemoryKey } from "../be/memory/index-content";
9
+ import { SqliteMemoryStore } from "../be/memory/providers/sqlite-store";
10
+ import { countCandidateDocuments, searchMemoryCandidates } from "../be/memory/search-candidates";
11
+
12
+ // Candidate starvation (PR #2302 review): rerank() collapses the chunks of one
13
+ // task_completion document, but the store's candidate window (limit x 3) is
14
+ // cut before the collapse. 32 high-similarity chunks of one task filled the
15
+ // whole window, so limit 3 returned one result while five relevant manual
16
+ // memories sat past the cutoff. Everything below the query embedding is real:
17
+ // the SQLite store (vec and hybrid paths), the HTTP routes and the MCP tool.
18
+ //
19
+ // The 128-chunk document is the round-2 case: the hybrid and FTS arms capped
20
+ // their fetch at 100 rows, so widening past 100 returned the same rows and
21
+ // read as exhaustion while the manual memories sat behind chunk 100.
22
+
23
+ /** Unit vector: `cos` on axis 2 (the query axis), the remainder on `axis`. */
24
+ function mix(cos: number, axis: number): Float32Array {
25
+ const embedding = new Float32Array(512);
26
+ embedding[2] = cos;
27
+ embedding[axis] = Math.sqrt(1 - cos * cos);
28
+ return embedding;
29
+ }
30
+
31
+ const queryEmbedding = mix(1, 3);
32
+
33
+ mock.module("../be/memory", () => ({
34
+ getEmbeddingProvider: () => ({
35
+ name: "test",
36
+ dimensions: 512,
37
+ embed: async () => queryEmbedding,
38
+ embedBatch: async (texts: string[]) => texts.map(() => queryEmbedding),
39
+ isConfigured: () => true,
40
+ }),
41
+ getMemoryStore: () => new SqliteMemoryStore(),
42
+ }));
43
+
44
+ const { handleMemory } = await import("../http/memory");
45
+ const { registerMemorySearchTool } = await import("../tools/memory-search");
46
+
47
+ const TEST_DB_PATH = "./test-memory-chunked-search-starvation.sqlite";
48
+ const QUERY = "starvation probe";
49
+ const MANUAL_DOCS = 5;
50
+ const LIMIT = 3;
51
+ let prevHybridFlag: string | undefined;
52
+
53
+ /** One agent per chunk count: agent-scope rows keep the scenarios apart. */
54
+ const scenarios = [32, 128].map((chunks) => ({
55
+ chunks,
56
+ agentId: randomUUID(),
57
+ manualIds: new Set<string>(),
58
+ }));
59
+
60
+ async function postJson(agentId: string, url: string, segments: string[]): Promise<string[]> {
61
+ const body = { query: QUERY, limit: LIMIT };
62
+ const req = Readable.from([Buffer.from(JSON.stringify(body))]) as IncomingMessage;
63
+ req.method = "POST";
64
+ req.url = url;
65
+ req.headers = {};
66
+ let payload: { results: { id: string }[] } = { results: [] };
67
+ const res = {
68
+ writeHead() {
69
+ return this;
70
+ },
71
+ end(chunk?: unknown) {
72
+ payload = typeof chunk === "string" ? JSON.parse(chunk) : (chunk as typeof payload);
73
+ return this;
74
+ },
75
+ } as unknown as ServerResponse;
76
+ expect(await handleMemory(req, res, segments, agentId)).toBe(true);
77
+ return payload.results.map((r) => r.id);
78
+ }
79
+
80
+ const searchHttp = (agentId: string) =>
81
+ postJson(agentId, "/api/memory/search", ["api", "memory", "search"]);
82
+ const listHttp = (agentId: string) =>
83
+ postJson(agentId, "/api/memory/list", ["api", "memory", "list"]);
84
+
85
+ async function searchTool(agentId: string): Promise<string[]> {
86
+ const server = new McpServer({ name: "chunked-search-starvation", version: "1.0.0" });
87
+ registerMemorySearchTool(server);
88
+ const tool = (
89
+ server as unknown as {
90
+ _registeredTools: Record<
91
+ string,
92
+ {
93
+ handler: (
94
+ args: unknown,
95
+ extra: unknown,
96
+ ) => Promise<{ structuredContent: { results: { id: string }[] } }>;
97
+ }
98
+ >;
99
+ }
100
+ )._registeredTools["memory-search"]!;
101
+ const result = await tool.handler(
102
+ { query: QUERY, limit: LIMIT, scope: "all" },
103
+ { sessionId: "starvation", requestInfo: { headers: { "x-agent-id": agentId } } },
104
+ );
105
+ return result.structuredContent.results.map((r) => r.id);
106
+ }
107
+
108
+ beforeAll(async () => {
109
+ prevHybridFlag = process.env.MEMORY_HYBRID_SEARCH;
110
+ for (const suffix of ["", "-wal", "-shm"]) {
111
+ try {
112
+ await unlink(TEST_DB_PATH + suffix);
113
+ } catch {}
114
+ }
115
+ initDb(TEST_DB_PATH);
116
+ const store = new SqliteMemoryStore();
117
+ for (const { chunks, agentId, manualIds } of scenarios) {
118
+ await createAgent({
119
+ id: agentId,
120
+ name: `Starvation Agent ${chunks}`,
121
+ isLead: false,
122
+ status: "idle",
123
+ });
124
+ const taskKey = taskCompletionMemoryKey(randomUUID());
125
+ // One long task output, every chunk closer to the query than any manual
126
+ // memory.
127
+ for (let i = 0; i < chunks; i++) {
128
+ const chunk = await store.store({
129
+ agentId,
130
+ scope: "agent",
131
+ name: "Task: long dreaming run",
132
+ content: `${QUERY} chunk ${i}: ${"lane starvation probe finding ".repeat(4)}`,
133
+ source: "task_completion",
134
+ key: taskKey,
135
+ chunkIndex: i,
136
+ totalChunks: chunks,
137
+ });
138
+ await store.updateEmbedding(chunk.id, mix(0.99 - i * 0.0001, 10 + i), "test");
139
+ }
140
+ for (let j = 0; j < MANUAL_DOCS; j++) {
141
+ const doc = await store.store({
142
+ agentId,
143
+ scope: "agent",
144
+ name: `manual note ${j}`,
145
+ content: `${QUERY} manual note ${j}`,
146
+ source: "manual",
147
+ });
148
+ manualIds.add(doc.id);
149
+ await store.updateEmbedding(doc.id, mix(0.8 - j * 0.01, 300 + j), "test");
150
+ }
151
+ }
152
+ });
153
+
154
+ afterEach(() => {
155
+ if (prevHybridFlag === undefined) delete process.env.MEMORY_HYBRID_SEARCH;
156
+ else process.env.MEMORY_HYBRID_SEARCH = prevHybridFlag;
157
+ });
158
+
159
+ afterAll(async () => {
160
+ closeDb();
161
+ for (const suffix of ["", "-wal", "-shm"]) {
162
+ try {
163
+ await unlink(TEST_DB_PATH + suffix);
164
+ } catch {}
165
+ }
166
+ });
167
+
168
+ describe("chunks of one task do not starve the result set", () => {
169
+ for (const { chunks, agentId, manualIds } of scenarios) {
170
+ for (const hybrid of ["0", "1"]) {
171
+ for (const [surface, search] of [
172
+ ["POST /api/memory/search", searchHttp],
173
+ ["POST /api/memory/list", listHttp],
174
+ ["memory-search tool", searchTool],
175
+ ] as const) {
176
+ test(`${chunks} chunks, ${surface}, hybrid=${hybrid}: limit ${LIMIT} returns ${LIMIT} documents`, async () => {
177
+ process.env.MEMORY_HYBRID_SEARCH = hybrid;
178
+ const ids = await search(agentId);
179
+ expect(ids).toHaveLength(LIMIT);
180
+ expect(new Set(ids).size).toBe(LIMIT);
181
+ // At most one slot for the task document; the rest are manual memories.
182
+ expect(ids.filter((id) => !manualIds.has(id)).length).toBeLessThanOrEqual(1);
183
+ });
184
+ }
185
+ }
186
+ }
187
+
188
+ test("FTS-only path (no query embedding) widens past 100 chunks of one task", async () => {
189
+ const { agentId, manualIds } = scenarios[1]!;
190
+ // A missing query embedding routes the store to searchFts.
191
+ const candidates = await searchMemoryCandidates(
192
+ new SqliteMemoryStore(),
193
+ new Float32Array(0),
194
+ agentId,
195
+ { limit: LIMIT, queryText: QUERY, scope: "all" },
196
+ );
197
+ expect(countCandidateDocuments(candidates)).toBeGreaterThanOrEqual(LIMIT);
198
+ expect(candidates.filter((c) => manualIds.has(c.id)).length).toBeGreaterThan(0);
199
+ });
200
+ });
@@ -0,0 +1,114 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ MAX_EMBEDDING_INPUT_CHARS,
4
+ OpenAIEmbeddingProvider,
5
+ } from "../be/memory/providers/openai-embedding";
6
+
7
+ // A gateway that rejects any input over REJECT_OVER chars the way the OpenAI
8
+ // API rejects an input over 8,192 tokens: a 400 for the whole request.
9
+ const REJECT_OVER = 10_000;
10
+ let server: ReturnType<typeof Bun.serve>;
11
+ const requests: string[][] = [];
12
+ const previousBaseUrl = process.env.EMBEDDING_API_BASE_URL;
13
+
14
+ beforeAll(() => {
15
+ server = Bun.serve({
16
+ hostname: "127.0.0.1",
17
+ port: 0,
18
+ async fetch(req) {
19
+ const body = (await req.json()) as { input: string | string[] };
20
+ const inputs = Array.isArray(body.input) ? body.input : [body.input];
21
+ requests.push(inputs);
22
+ const tooLong = inputs.findIndex((input) => input.length > REJECT_OVER);
23
+ if (tooLong !== -1) {
24
+ const param = Array.isArray(body.input) ? `input[${tooLong}]` : "input";
25
+ return Response.json(
26
+ {
27
+ error: {
28
+ message: `Invalid '${param}': maximum input length is 8192 tokens.`,
29
+ type: "invalid_request_error",
30
+ param: null,
31
+ code: null,
32
+ },
33
+ },
34
+ { status: 400 },
35
+ );
36
+ }
37
+ return Response.json({
38
+ object: "list",
39
+ data: inputs.map((input, index) => ({
40
+ object: "embedding",
41
+ index,
42
+ embedding: [input.length, 0, 0, 1],
43
+ })),
44
+ model: "test-embedding-model",
45
+ usage: { prompt_tokens: 1, total_tokens: 1 },
46
+ });
47
+ },
48
+ });
49
+ process.env.EMBEDDING_API_BASE_URL = `http://127.0.0.1:${server.port}/v1`;
50
+ });
51
+
52
+ afterAll(() => {
53
+ server.stop(true);
54
+ if (previousBaseUrl === undefined) delete process.env.EMBEDDING_API_BASE_URL;
55
+ else process.env.EMBEDDING_API_BASE_URL = previousBaseUrl;
56
+ });
57
+
58
+ beforeEach(() => {
59
+ requests.length = 0;
60
+ });
61
+
62
+ function provider(): OpenAIEmbeddingProvider {
63
+ return new OpenAIEmbeddingProvider({
64
+ apiKey: "test-key",
65
+ model: "test-embedding-model",
66
+ dimensions: 4,
67
+ });
68
+ }
69
+
70
+ /** The embedded length, which the fake gateway echoes as the first component. */
71
+ function embeddedLength(vector: Float32Array | null): number | null {
72
+ return vector ? vector[0]! : null;
73
+ }
74
+
75
+ describe("embedding input cap", () => {
76
+ test("an over-cap input is cut down until the provider accepts it", async () => {
77
+ const vector = await provider().embed("x".repeat(50_000));
78
+
79
+ expect(embeddedLength(vector)).toBe(MAX_EMBEDDING_INPUT_CHARS / 4);
80
+ expect(requests.map((r) => r[0]!.length)).toEqual([
81
+ MAX_EMBEDDING_INPUT_CHARS,
82
+ MAX_EMBEDDING_INPUT_CHARS / 2,
83
+ MAX_EMBEDDING_INPUT_CHARS / 4,
84
+ ]);
85
+ });
86
+
87
+ test("no input is ever sent over the char cap", async () => {
88
+ await provider().embedBatch(["a".repeat(100_000), "b".repeat(30_000)]);
89
+ for (const request of requests) {
90
+ for (const input of request)
91
+ expect(input.length).toBeLessThanOrEqual(MAX_EMBEDDING_INPUT_CHARS);
92
+ }
93
+ });
94
+
95
+ test("one oversized input in a batch does not fail the other inputs", async () => {
96
+ const vectors = await provider().embedBatch(["small one", "y".repeat(40_000), "small two"]);
97
+
98
+ expect(vectors.map(embeddedLength)).toEqual([
99
+ "small one".length,
100
+ MAX_EMBEDDING_INPUT_CHARS / 4,
101
+ "small two".length,
102
+ ]);
103
+ // The whole batch first, then each input alone after the rejection.
104
+ expect(requests[0]).toHaveLength(3);
105
+ expect(requests.slice(1).every((r) => r.length === 1)).toBe(true);
106
+ });
107
+
108
+ test("a batch with no oversized input goes out as one request", async () => {
109
+ const vectors = await provider().embedBatch(["one", "", "three"]);
110
+
111
+ expect(vectors.map(embeddedLength)).toEqual([3, null, 5]);
112
+ expect(requests).toEqual([["one", "three"]]);
113
+ });
114
+ });
@@ -214,6 +214,20 @@ describe("Memory System", () => {
214
214
  expect(xChars).toBeGreaterThanOrEqual(2500);
215
215
  });
216
216
 
217
+ test("keeps sections under MIN_CHUNK_SIZE, packed into shared chunks", () => {
218
+ const preamble = "Report preamble with enough words to stand as its own chunk here.";
219
+ const sections = Array.from({ length: 100 }, (_, i) => `## Lane ${i}\n\nfact-${i} ok`);
220
+ const chunks = chunkContent([preamble, ...sections].join("\n\n"));
221
+
222
+ const stored = chunks.map((c) => c.content).join("\n");
223
+ for (let i = 0; i < 100; i++) {
224
+ expect(stored).toContain(`## Lane ${i}\n\nfact-${i} ok`);
225
+ }
226
+ expect(chunks[0]!.content).toBe(preamble);
227
+ expect(chunks.length).toBeLessThan(6);
228
+ for (const chunk of chunks) expect(chunk.content.length).toBeLessThanOrEqual(2000);
229
+ });
230
+
217
231
  test("chunk indices are sequential", () => {
218
232
  const longText = "A ".repeat(3000);
219
233
  const chunks = chunkContent(longText);