@desplega.ai/agent-swarm 2.5.2 → 2.5.3

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 (143) hide show
  1. package/dist/{acp-adapter-csh0zz3b.js → acp-adapter-gr2fcyyj.js} +3 -3
  2. package/dist/{actions-6xx079d1.js → actions-wv766ghf.js} +18 -18
  3. package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-speb906t.js} +3 -3
  4. package/dist/{app-ft6r6v23.js → app-ag29n14f.js} +3 -3
  5. package/dist/{assistant-vt2cyfzd.js → assistant-3x8bz3eg.js} +19 -19
  6. package/dist/{boot-reembed-r3e808ky.js → boot-reembed-5b3d2eea.js} +1 -1
  7. package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-jsmfrpzf.js} +1 -1
  8. package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-s73gerxy.js} +2 -2
  9. package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-5zmax2wr.js} +4 -4
  10. package/dist/{claude-adapter-s7j47k06.js → claude-adapter-hswdnm3t.js} +3 -3
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
  12. package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-3k8m3hhc.js} +2 -2
  13. package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-fkbyk66x.js} +2 -2
  14. package/dist/{cli-d172svcw.js → cli-1fc85mvt.js} +4 -4
  15. package/dist/{cli-3e9vs93g.js → cli-210xd1f4.js} +4 -4
  16. package/dist/{cli-vaxw3bg0.js → cli-25j01dpj.js} +3 -3
  17. package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
  18. package/dist/{cli-fgn7yg5r.js → cli-3ngdk5te.js} +1 -1
  19. package/dist/{cli-7ccf7akh.js → cli-40jsnrjm.js} +2 -2
  20. package/dist/{cli-b1tv22wr.js → cli-502eqv93.js} +1 -1
  21. package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
  22. package/dist/{cli-f55b6ky0.js → cli-8s5wx3nz.js} +2 -2
  23. package/dist/{cli-86zpfje5.js → cli-99xgw26r.js} +1 -1
  24. package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
  25. package/dist/{cli-h5d22pfs.js → cli-aecdcr77.js} +119 -282
  26. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  27. package/dist/{cli-15xd0j7w.js → cli-ce0ak9ms.js} +11 -11
  28. package/dist/{cli-y3dzwyy3.js → cli-e2ar4bp1.js} +71 -20
  29. package/dist/{cli-b6tnjdvx.js → cli-e8jyq7d8.js} +1 -1
  30. package/dist/{cli-6y43v79h.js → cli-egjggehm.js} +1 -1
  31. package/dist/{cli-cf63dnxj.js → cli-ewm3pwbq.js} +2 -2
  32. package/dist/{cli-bpbcpd8b.js → cli-fsdprwrg.js} +1 -1
  33. package/dist/{cli-qrjv7t18.js → cli-fzcn3d87.js} +1 -1
  34. package/dist/{cli-14v1sxxy.js → cli-gpsz7g88.js} +6 -6
  35. package/dist/{cli-wadzapwb.js → cli-hcpj2xqz.js} +1 -1
  36. package/dist/{cli-jmcjbjkt.js → cli-hhxjnjzs.js} +1 -1
  37. package/dist/{cli-j3dwd4kz.js → cli-jjznk3f7.js} +718 -108
  38. package/dist/{cli-csh8fnbg.js → cli-kxawjrxg.js} +147 -28
  39. package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
  40. package/dist/{cli-0vgy5wfw.js → cli-n45mr7zh.js} +2 -2
  41. package/dist/{cli-aaqnqpyw.js → cli-p0rzjkya.js} +2 -2
  42. package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
  43. package/dist/{cli-e91vshb1.js → cli-p9rafdrb.js} +4 -4
  44. package/dist/{cli-edtm741g.js → cli-qgf8emrn.js} +5 -5
  45. package/dist/{cli-ha22h0eg.js → cli-qzme5949.js} +26 -26
  46. package/dist/{cli-xzge46sj.js → cli-s49hpn37.js} +2 -2
  47. package/dist/{cli-b63d08rs.js → cli-s53vp709.js} +1 -1
  48. package/dist/{cli-cyfgxnvb.js → cli-svrj71pg.js} +18 -16
  49. package/dist/{cli-4qkg9968.js → cli-vwn81jbs.js} +3 -3
  50. package/dist/{cli-f3pfn4x3.js → cli-w1zwctj2.js} +1 -1
  51. package/dist/{cli-kcnwn85g.js → cli-y561h5a9.js} +4 -4
  52. package/dist/{cli-n2918sed.js → cli-z21whrrg.js} +15 -15
  53. package/dist/cli.js +16 -16
  54. package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-n8ka66vf.js} +2 -2
  55. package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-fsebts37.js} +1 -1
  56. package/dist/{commands-1cnvseqx.js → commands-t04rt2pc.js} +2 -2
  57. package/dist/{controller-kc7s9xhn.js → controller-rgna04r2.js} +2 -2
  58. package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-bqm1rtft.js} +20 -20
  59. package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-swr2k95y.js} +1 -1
  60. package/dist/{db-v5ytg5pw.js → db-24ck09xy.js} +1 -1
  61. package/dist/{db-4vq88jkc.js → db-a4m7haw0.js} +3 -3
  62. package/dist/{dispatcher-trtcrqae.js → dispatcher-nwf9ss0q.js} +17 -17
  63. package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-kpdnhjst.js} +2 -2
  64. package/dist/{external-channel-n9bymc2n.js → external-channel-j1e2p34p.js} +2 -2
  65. package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-qtjz0whk.js} +4 -4
  66. package/dist/{handlers-ptsraqa8.js → handlers-19xr6dnz.js} +20 -20
  67. package/dist/{heartbeat-60qtxtjf.js → heartbeat-vtesda5d.js} +20 -20
  68. package/dist/{hook-rbktcstg.js → hook-8wqaszda.js} +1 -1
  69. package/dist/{hook-ngtvh729.js → hook-b2hhj6rd.js} +1 -1
  70. package/dist/{http-z8xy72gp.js → http-mxcqhhcs.js} +2757 -2448
  71. package/dist/{index-a9nsavhx.js → index-1r8wfpzs.js} +1 -1
  72. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  73. package/dist/{index-s193j5fh.js → index-kw2mmat7.js} +20 -20
  74. package/dist/{index-06qkmcx6.js → index-n1vbvta9.js} +17 -9
  75. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  76. package/dist/{index-1cscynkb.js → index-spwxjgay.js} +17 -17
  77. package/dist/{index-r70q0xjx.js → index-srg522m6.js} +17 -17
  78. package/dist/{keepalive-a9srcphj.js → keepalive-dsd8rtgx.js} +3 -3
  79. package/dist/{lead-3devneqy.js → lead-zyerk1x2.js} +30 -30
  80. package/dist/{maintenance-0er16h2y.js → maintenance-jqmqwnet.js} +1 -1
  81. package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
  82. package/dist/{onboard-wqywz5py.js → onboard-w4yd7s8t.js} +2 -2
  83. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
  84. package/dist/{otel-impl-vk32s884.js → otel-impl-f36byzbd.js} +1 -1
  85. package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-xvak5p4v.js} +3 -3
  86. package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-682k2mt5.js} +1 -1
  87. package/dist/{rbac-roles-452665y0.js → rbac-roles-gfn30fb8.js} +2 -2
  88. package/dist/{render-v2-0ttsk2j4.js → render-v2-hgytz8nn.js} +3 -3
  89. package/dist/{resume-a26kvr41.js → resume-2dvq348g.js} +7 -5
  90. package/dist/{session-access-dgh9nkwk.js → session-access-7f21ekq9.js} +4 -4
  91. package/dist/{setup-9vc9qahm.js → setup-epffxtbk.js} +1 -1
  92. package/dist/{work-objects-vn8nkxwn.js → work-objects-xz1a5rqk.js} +20 -20
  93. package/dist/{worker-1x1h8vnm.js → worker-42t8emjt.js} +30 -30
  94. package/openapi.json +3045 -2456
  95. package/package.json +4 -4
  96. package/src/be/approval-config-question.ts +389 -0
  97. package/src/be/automation-preflight.ts +37 -3
  98. package/src/be/db/session-tree.ts +73 -15
  99. package/src/be/db/skill-search.ts +87 -0
  100. package/src/be/db/tasks/read.ts +29 -2
  101. package/src/be/db/workflow-runs.ts +54 -7
  102. package/src/be/db.ts +25 -5
  103. package/src/be/migrations/239_skills_fts.sql +40 -0
  104. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  105. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  106. package/src/be/swarm-config-guard.ts +23 -0
  107. package/src/extensions/catalog.generated.json +4 -4
  108. package/src/http/approval-requests.ts +209 -38
  109. package/src/http/config.ts +22 -37
  110. package/src/http/sessions.ts +27 -2
  111. package/src/integrations/chat-sdk/inbound.ts +5 -4
  112. package/src/providers/amp-adapter.ts +1 -1
  113. package/src/providers/dsh-adapter.ts +1 -1
  114. package/src/tests/approval-config-question.test.ts +702 -0
  115. package/src/tests/chat-sdk-inbound.test.ts +20 -0
  116. package/src/tests/dsh-adapter.test.ts +1 -1
  117. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  118. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  119. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  120. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  121. package/src/tests/script-connections.test.ts +6 -0
  122. package/src/tests/sessions-tree.test.ts +168 -1
  123. package/src/tests/skill-search-long-content.test.ts +193 -0
  124. package/src/tests/skill-search.test.ts +173 -22
  125. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  126. package/src/tools/request-human-input.ts +27 -5
  127. package/src/types.ts +7 -0
  128. package/src/workflows/approval-notifications.ts +10 -3
  129. package/src/workflows/checkpoint.ts +69 -35
  130. package/src/workflows/engine.ts +234 -87
  131. package/src/workflows/executors/base.ts +6 -0
  132. package/src/workflows/executors/human-in-the-loop.ts +25 -1
  133. package/src/workflows/executors/sub-workflow.ts +45 -16
  134. package/src/workflows/recovery.ts +63 -1
  135. package/src/workflows/resume.ts +60 -21
  136. package/src/workflows/retry-poller.ts +25 -4
  137. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  138. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  139. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  140. package/templates/skills/turso-interaction/content.md +1 -1
  141. package/templates/skills/workflow-iterate/content.md +8 -1
  142. /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
  143. /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
@@ -4,6 +4,7 @@ import { keyPrefixRange, sessionGroupKey } from "../assets/key";
4
4
  import {
5
5
  closeDb,
6
6
  countSessions,
7
+ createSteeringMessage,
7
8
  createTaskExtended,
8
9
  createUser,
9
10
  getDbClient,
@@ -58,6 +59,7 @@ beforeAll(async () => {
58
59
  });
59
60
  beforeEach(async () => {
60
61
  await getDbClient().run("DELETE FROM approval_requests");
62
+ await getDbClient().run("DELETE FROM task_steering_messages");
61
63
  await getDbClient().run("DELETE FROM inbox_item_state");
62
64
  await getDbClient().run("DELETE FROM session_shares");
63
65
  await getDbClient().run("DELETE FROM agent_tasks");
@@ -195,7 +197,8 @@ describe("chain-wide read state", () => {
195
197
  { activity: "2026-10-14T12:00:00.000Z", expected: "read" },
196
198
  { status: "dismissed", updated: OLD, expected: "done" },
197
199
  { status: "done", updated: NOW, expected: "done" },
198
- { status: "done", updated: OLD, expected: "unread" },
200
+ // Agent activity after the Done mark keeps it Done.
201
+ { status: "done", updated: OLD, expected: "done" },
199
202
  { status: "snoozed", snoozeUntil: "2026-10-22T12:00:00.000Z", expected: "later" },
200
203
  { status: "snoozed", snoozeUntil: NOW, expected: "unread" },
201
204
  { status: "snoozed", expected: "unread" },
@@ -259,6 +262,145 @@ describe("chain-wide read state", () => {
259
262
  });
260
263
  });
261
264
 
265
+ describe("Done holds until the session needs the user", () => {
266
+ const DONE_AT = "2026-10-21T11:00:00.000Z";
267
+ const BEFORE = "2026-10-21T10:30:00.000Z";
268
+ const AFTER = "2026-10-21T11:30:00.000Z";
269
+ const readState = async (id: string) =>
270
+ (await listRecentSessions(filters({ limit: 100 }))).find((s) => s.root.id === id)?.readState;
271
+ const doneSession = async () => {
272
+ const root = await task("shared/", { source: "ui" }, BEFORE);
273
+ await getDbClient().run(
274
+ "UPDATE agent_tasks SET createdAt = ?, status = 'in_progress' WHERE id = ?",
275
+ [BEFORE, root.id],
276
+ );
277
+ await state(root.id, "done", DONE_AT);
278
+ expect(await readState(root.id)).toBe("done");
279
+ return root;
280
+ };
281
+ const child = async (
282
+ parentTaskId: string,
283
+ extra: Parameters<typeof createTaskExtended>[1],
284
+ createdAt = AFTER,
285
+ ) => {
286
+ const row = await createTaskExtended("Child", { parentTaskId, key: "shared/", ...extra });
287
+ await getDbClient().run(
288
+ "UPDATE agent_tasks SET createdAt = ?, lastUpdatedAt = ?, status = 'completed' WHERE id = ?",
289
+ [createdAt, createdAt, row.id],
290
+ );
291
+ return row;
292
+ };
293
+ const approval = async (taskId: string, createdAt: string, status = "pending") =>
294
+ getDbClient().run(
295
+ `INSERT INTO approval_requests (id, sourceTaskId, title, questions, approvers, status, createdAt)
296
+ VALUES (?, ?, 'Approve deploy', '[]', '{}', ?, ?)`,
297
+ [crypto.randomUUID(), taskId, status, createdAt],
298
+ );
299
+
300
+ test("agent progress and a status change keep it Done", async () => {
301
+ const root = await doneSession();
302
+ await getDbClient().run(
303
+ "UPDATE agent_tasks SET lastUpdatedAt = ?, status = 'completed' WHERE id = ?",
304
+ [AFTER, root.id],
305
+ );
306
+ expect(await readState(root.id)).toBe("done");
307
+ const tree = await getSessionTree(actor(), NOW);
308
+ expect(tree.done.total).toBe(1);
309
+ expect(tree.inbox.total).toBe(0);
310
+ });
311
+
312
+ test("agent-created child and delegated tasks keep it Done", async () => {
313
+ const root = await doneSession();
314
+ // Inherits the requester from its parent.
315
+ const inherited = await child(root.id, { source: "mcp" });
316
+ // Lead delegation, a deferred wake-up and a follow-up name the requester explicitly.
317
+ await child(inherited.id, { source: "mcp", requestedByUserId: owner.id });
318
+ await child(root.id, { source: "schedule", requestedByUserId: owner.id });
319
+ await child(root.id, { source: "system", requestedByUserId: owner.id });
320
+ await child(root.id, { source: "workflow", requestedByUserId: owner.id });
321
+ expect(await readState(root.id)).toBe("done");
322
+ });
323
+
324
+ test("a pending approval created after Done returns it to the Inbox", async () => {
325
+ const before = await doneSession();
326
+ await approval(before.id, BEFORE);
327
+ const resolved = await doneSession();
328
+ await approval(resolved.id, AFTER, "approved");
329
+ expect(await readState(before.id)).toBe("done");
330
+ expect(await readState(resolved.id)).toBe("done");
331
+
332
+ const root = await doneSession();
333
+ const agent = await child(root.id, { source: "mcp" });
334
+ await approval(agent.id, AFTER);
335
+ expect(await readState(root.id)).toBe("unread");
336
+ expect(await ids({ triage: "inbox" })).toEqual([root.id]);
337
+ });
338
+
339
+ test("a human follow-up after Done returns it to the Inbox", async () => {
340
+ const earlier = await doneSession();
341
+ await child(earlier.id, { source: "ui", requestedByUserId: owner.id }, BEFORE);
342
+ expect(await readState(earlier.id)).toBe("done");
343
+
344
+ const root = await doneSession();
345
+ await child(root.id, { source: "ui", requestedByUserId: owner.id });
346
+ expect(await readState(root.id)).toBe("unread");
347
+ const slack = await doneSession();
348
+ await child(slack.id, { source: "slack", requestedByUserId: other.id });
349
+ expect(await readState(slack.id)).toBe("unread");
350
+ const tree = await getSessionTree(actor(), NOW);
351
+ expect(tree.inbox.total).toBe(2);
352
+ expect(tree.done.total).toBe(1);
353
+ });
354
+
355
+ test("a person's steer or queued message after Done returns it to the Inbox", async () => {
356
+ // The real writer stamps created_at with the column default, after DONE_AT.
357
+ const steered = await doneSession();
358
+ await createSteeringMessage({
359
+ taskId: steered.id,
360
+ body: "Change course",
361
+ mode: "steer",
362
+ source: "ui",
363
+ createdByKind: "user",
364
+ createdByUserId: owner.id,
365
+ });
366
+ expect(await readState(steered.id)).toBe("unread");
367
+
368
+ const queued = await doneSession();
369
+ const agent = await child(queued.id, { source: "mcp" });
370
+ await createSteeringMessage({
371
+ taskId: agent.id,
372
+ body: "Also check this",
373
+ mode: "queue",
374
+ source: "slack",
375
+ createdByKind: "user",
376
+ createdByUserId: owner.id,
377
+ });
378
+ expect(await readState(queued.id)).toBe("unread");
379
+ expect((await ids({ triage: "inbox" })).sort()).toEqual([steered.id, queued.id].sort());
380
+ });
381
+
382
+ test("agent, system, cancelled and earlier steering keep it Done", async () => {
383
+ const steer = async (
384
+ taskId: string,
385
+ createdByKind: "user" | "agent" | "system",
386
+ createdAt = AFTER,
387
+ status = "pending",
388
+ ) =>
389
+ getDbClient().run(
390
+ `INSERT INTO task_steering_messages
391
+ (id, task_id, body, mode, status, source, created_by_kind, created_at)
392
+ VALUES (?, ?, 'Steer', 'queue', ?, 'mcp', ?, ?)`,
393
+ [crypto.randomUUID(), taskId, status, createdByKind, createdAt],
394
+ );
395
+ const root = await doneSession();
396
+ await steer(root.id, "agent");
397
+ await steer(root.id, "system");
398
+ await steer(root.id, "user", AFTER, "cancelled");
399
+ await steer(root.id, "user", BEFORE, "handled");
400
+ expect(await readState(root.id)).toBe("done");
401
+ });
402
+ });
403
+
262
404
  test("needs attention uses the latest failed status and pending approvals anywhere in the chain", async () => {
263
405
  const failed = await task();
264
406
  const child = await task("shared/a/", { parentTaskId: failed.id }, NOW);
@@ -591,6 +733,31 @@ describe("source filter and list triage", () => {
591
733
  );
592
734
  });
593
735
 
736
+ test("showSource keeps only the listed sources, and an empty list keeps none", async () => {
737
+ await task("shared/", { source: "slack" });
738
+ const ui = await task("shared/a/", { source: "ui" });
739
+ await task("shared/", { source: "github" });
740
+
741
+ const full = await treeOf();
742
+ const shown = await treeOf("showSource=ui");
743
+ expect(shown.inbox.total).toBe(1);
744
+ expect(shown.groups.map((g) => g.key)).toEqual(["shared/a/"]);
745
+ expect(shown.sources).toEqual(full.sources);
746
+ const inbox = await listOf("triage=inbox&showSource=ui");
747
+ expect(inbox.total).toBe(1);
748
+ expect(inbox.sessions.map((s) => s.root.id)).toEqual([ui.id]);
749
+
750
+ const none = await treeOf("showSource=");
751
+ expect(none.inbox.total).toBe(0);
752
+ expect(none.sources).toEqual(full.sources);
753
+ expect((await listOf("triage=inbox&showSource=")).total).toBe(0);
754
+
755
+ expect((await triage({ action: "done", triage: "inbox", showSource: ["ui"] })).status).toBe(
756
+ 200,
757
+ );
758
+ expect((await listOf("triage=done")).sessions.map((s) => s.root.id)).toEqual([ui.id]);
759
+ });
760
+
594
761
  test("triage by list writes every matching session, honoring the list and the filters", async () => {
595
762
  const a = await task("shared/", { source: "ui" });
596
763
  const b = await task("shared/", { source: "ui" }, OLD);
@@ -0,0 +1,193 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3
+ import { unlink } from "node:fs/promises";
4
+ import {
5
+ closeDb,
6
+ createSkill,
7
+ deleteSkill,
8
+ getDb,
9
+ initDb,
10
+ searchSkills,
11
+ updateSkill,
12
+ } from "../be/db";
13
+
14
+ // Natural-language probes that missed on the live catalog: the obvious skill
15
+ // has a long SKILL.md body, and a shorter skill repeats the query words in
16
+ // its content. bm25 over the whole row buried the name/description hit.
17
+ const TEST_DB_PATH = `./test-skill-search-long-content-${process.pid}.sqlite`;
18
+
19
+ async function removeDbFiles(path: string): Promise<void> {
20
+ for (const suffix of ["", "-wal", "-shm"]) {
21
+ await unlink(path + suffix).catch(() => {});
22
+ }
23
+ }
24
+
25
+ async function topNames(query: string): Promise<string[]> {
26
+ return (await searchSkills(query, 10, false)).map((skill) => skill.name);
27
+ }
28
+
29
+ // A long body of words that no probe uses, like a full SKILL.md.
30
+ const longBody = (topic: string) =>
31
+ `${topic}\n${"Each step lists its inputs, outputs and the checks to run afterwards. ".repeat(60)}`;
32
+
33
+ describe("searchSkills on long skill bodies", () => {
34
+ beforeAll(async () => {
35
+ await removeDbFiles(TEST_DB_PATH);
36
+ initDb(TEST_DB_PATH);
37
+
38
+ const fixture = [
39
+ {
40
+ name: "pages",
41
+ description: "Publish hosted HTML pages with share links for outside readers.",
42
+ content: longBody("Hosted artifacts."),
43
+ },
44
+ {
45
+ name: "newsletter-sources",
46
+ description: "Weekly AI newsletter source list and reading picks.",
47
+ content: "Publish a short note for a page you read, then publish the page list. ".repeat(6),
48
+ },
49
+ {
50
+ name: "browser-interaction",
51
+ description: "Drive a headless browser.",
52
+ content: "Open a page, wait for a page to load, then read a page. ".repeat(6),
53
+ },
54
+ {
55
+ name: "scheduling",
56
+ description: "Recurring jobs with cron expressions: create, pause or resume a schedule.",
57
+ content: longBody("Cron jobs."),
58
+ },
59
+ {
60
+ name: "social-content",
61
+ description: "Draft social posts for the brand accounts.",
62
+ content: "How do I create a post? Create a draft, then schedule a post for a slot. ".repeat(
63
+ 6,
64
+ ),
65
+ },
66
+ {
67
+ name: "code-quality",
68
+ description: "Checks to run before you push or open a pull request, and how to review one.",
69
+ content: longBody("Pre-push gate."),
70
+ },
71
+ {
72
+ name: "customer-ops",
73
+ description: "Operations runbook for one customer deployment.",
74
+ content:
75
+ "Open a pull request, ask a reviewer, review a pull request, request a fix. ".repeat(6),
76
+ },
77
+ // "get" is a stopword, and "aaa-get-helper" sorts first by name.
78
+ { name: "get", description: "Fetch API resources.", content: "fetch resources" },
79
+ { name: "aaa-get-helper", description: "Get helper instructions", content: "get content" },
80
+ { name: "what is it", description: "Release notes digest.", content: "notes" },
81
+ { name: "aaa-what-is-it", description: "What is it for, in short.", content: "notes" },
82
+ ];
83
+ for (const skill of fixture) await createSkill({ ...skill, scope: "swarm" });
84
+ });
85
+
86
+ afterAll(async () => {
87
+ closeDb();
88
+ await removeDbFiles(TEST_DB_PATH);
89
+ });
90
+
91
+ test.each([
92
+ ["publish a page", "pages"],
93
+ ["how do I create a schedule", "scheduling"],
94
+ ["how do I review a pull request", "code-quality"],
95
+ ])("%p ranks %p first over content that repeats the query words", async (query, expected) => {
96
+ expect((await topNames(query))[0]).toBe(expected);
97
+ });
98
+
99
+ test("stopwords do not match every skill", async () => {
100
+ // Every fixture body contains "a"; only skills about pages may match.
101
+ expect(await topNames("publish a page")).not.toContain("code-quality");
102
+ });
103
+
104
+ test("a query of only stopwords falls back to the substring match", async () => {
105
+ const names = await topNames("how do I");
106
+ expect(names).toContain("social-content");
107
+ expect(names).not.toContain("pages");
108
+ });
109
+
110
+ test.each([
111
+ "get",
112
+ "GET",
113
+ " Get ",
114
+ ])("an exact-name query of only stopwords (%p) ranks that skill first", async (query) => {
115
+ expect((await searchSkills(query, 1, false)).map((skill) => skill.name)).toEqual(["get"]);
116
+ expect((await topNames(query)).slice(0, 2)).toEqual(["get", "aaa-get-helper"]);
117
+ });
118
+
119
+ test("a multi-word exact name of only stopwords ranks first", async () => {
120
+ expect((await searchSkills("What Is It", 1, false)).map((skill) => skill.name)).toEqual([
121
+ "what is it",
122
+ ]);
123
+ });
124
+
125
+ test("keeps the name/description index in sync on update and delete", async () => {
126
+ const skill = await createSkill({
127
+ name: "meta-sync-probe",
128
+ description: "Starts with gammaword.",
129
+ content: "",
130
+ scope: "swarm",
131
+ });
132
+ const metaRows = (term: string) =>
133
+ getDb()
134
+ .query<{ skill_id: string }, [string]>(
135
+ "SELECT skill_id FROM skills_meta_fts WHERE skills_meta_fts MATCH ?",
136
+ )
137
+ .all(`"${term}"`)
138
+ .map((row) => row.skill_id);
139
+ expect(metaRows("gammaword")).toEqual([skill.id]);
140
+
141
+ await updateSkill(skill.id, { description: "Now about deltaword." });
142
+ expect(metaRows("gammaword")).toEqual([]);
143
+ expect(metaRows("deltaword")).toEqual([skill.id]);
144
+
145
+ await deleteSkill(skill.id);
146
+ expect(metaRows("deltaword")).toEqual([]);
147
+ });
148
+
149
+ test("migrated schema carries the name/description sync triggers", () => {
150
+ const triggers = getDb()
151
+ .query<{ name: string }, []>(
152
+ "SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = 'skills' AND name LIKE 'skills_meta_fts_%' ORDER BY name",
153
+ )
154
+ .all()
155
+ .map((row) => row.name);
156
+ expect(triggers).toEqual([
157
+ "skills_meta_fts_delete",
158
+ "skills_meta_fts_insert",
159
+ "skills_meta_fts_update",
160
+ ]);
161
+ });
162
+ });
163
+
164
+ test("migration 241 backfills skills that existed before it ran", async () => {
165
+ const db = new Database(":memory:");
166
+ try {
167
+ db.exec(await Bun.file("src/be/migrations/019_skills.sql").text());
168
+ db.run(
169
+ "INSERT INTO skills (id, name, description, content, type, scope, createdAt, lastUpdatedAt) VALUES ('s1', 'legacy-skill', 'Old description', 'old body', 'remote', 'swarm', '2026-01-01', '2026-01-01')",
170
+ );
171
+ const migration = await Bun.file("src/be/migrations/241_skills_meta_fts.sql").text();
172
+ db.exec(migration);
173
+
174
+ const match = (term: string) =>
175
+ db
176
+ .query<{ skill_id: string }, [string]>(
177
+ "SELECT skill_id FROM skills_meta_fts WHERE skills_meta_fts MATCH ?",
178
+ )
179
+ .all(term)
180
+ .map((row) => row.skill_id);
181
+ expect(match('"description"')).toEqual(["s1"]);
182
+ // Content is not indexed here.
183
+ expect(match('"body"')).toEqual([]);
184
+
185
+ // Re-running the migration does not duplicate backfilled rows.
186
+ db.exec(migration);
187
+ expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM skills_meta_fts").get()?.n).toBe(
188
+ 1,
189
+ );
190
+ } finally {
191
+ db.close();
192
+ }
193
+ });
@@ -1,6 +1,15 @@
1
+ import { Database } from "bun:sqlite";
1
2
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
3
  import { unlink } from "node:fs/promises";
3
- import { closeDb, createSkill, initDb, searchSkills } from "../be/db";
4
+ import {
5
+ closeDb,
6
+ createSkill,
7
+ deleteSkill,
8
+ getDb,
9
+ initDb,
10
+ searchSkills,
11
+ updateSkill,
12
+ } from "../be/db";
4
13
 
5
14
  const TEST_DB_PATH = `./test-skill-search-${process.pid}.sqlite`;
6
15
 
@@ -10,22 +19,69 @@ async function removeDbFiles(path: string): Promise<void> {
10
19
  }
11
20
  }
12
21
 
22
+ async function topNames(query: string, limit?: number): Promise<string[]> {
23
+ return (await searchSkills(query, limit)).map((skill) => skill.name);
24
+ }
25
+
13
26
  describe("searchSkills", () => {
14
27
  beforeAll(async () => {
15
28
  await removeDbFiles(TEST_DB_PATH);
16
29
  initDb(TEST_DB_PATH);
17
30
 
31
+ const fixture = [
32
+ {
33
+ name: "pr-review-fix-loop",
34
+ description: "Run review fix rounds on a pull request until CI is green.",
35
+ content: "Reply to each review thread, push the fix, wait for checks.",
36
+ },
37
+ {
38
+ name: "code-quality",
39
+ description: "Pre-push checklist for any code change.",
40
+ content: "Run lint, typecheck and tests, then read the diff as a reviewer would.",
41
+ },
42
+ {
43
+ name: "swarm-scripts",
44
+ description: "Author and run reusable TypeScript in the swarm.",
45
+ content: "script-upsert, script-run, credential bindings and secrets stay server-side.",
46
+ },
47
+ {
48
+ name: "agent-fs",
49
+ description: "Shared drive for documents between agents and people.",
50
+ content: "Upload markdown and create share links for outside readers.",
51
+ },
52
+ {
53
+ name: "scheduling",
54
+ description: "Create recurring jobs with cron expressions.",
55
+ content: "Pick a timezone, set the next run, pause or resume a schedule.",
56
+ },
57
+ {
58
+ name: "schedule-audit",
59
+ description: "Audit scheduling drift and missed schedules.",
60
+ content: "Compare each schedule's last run with its cron.",
61
+ },
62
+ {
63
+ name: "memory",
64
+ description: "Store, search and rate learnings from past sessions.",
65
+ content: "Use memory-store for a fix that will come back.",
66
+ },
67
+ {
68
+ name: "call-time-trap-placement",
69
+ description: "Decide where operational rules belong.",
70
+ content: "Use toolsMd and claudeMd to document the delivery surface.",
71
+ },
72
+ {
73
+ name: "content-search-example",
74
+ description: "A skill with a content-only term.",
75
+ content: "This body contains the needle-token.",
76
+ },
77
+ ];
78
+ for (const skill of fixture) await createSkill({ ...skill, scope: "swarm" });
18
79
  await createSkill({
19
- name: "call-time-trap-placement",
20
- description: "Decide where operational rules belong.",
21
- content: "Use toolsMd and claudeMd to document the delivery surface.",
22
- scope: "swarm",
23
- });
24
- await createSkill({
25
- name: "content-search-example",
26
- description: "A skill with a content-only term.",
27
- content: "This body contains the needle-token.",
80
+ name: "disabled-cron-helper",
81
+ description: "Recurring cron jobs, the old way.",
82
+ content: "cron cron cron schedule",
28
83
  scope: "swarm",
84
+ isEnabled: false,
29
85
  });
30
86
  });
31
87
 
@@ -34,27 +90,122 @@ describe("searchSkills", () => {
34
90
  await removeDbFiles(TEST_DB_PATH);
35
91
  });
36
92
 
37
- test("matches every whitespace-separated token across searchable fields", async () => {
38
- const skills = await searchSkills("call-time trap placement toolsMd claudeMd delivery surface");
93
+ test("ranks a name match first", async () => {
94
+ expect((await topNames("scheduling"))[0]).toBe("scheduling");
95
+ expect((await topNames("code quality"))[0]).toBe("code-quality");
96
+ });
97
+
98
+ test("ranks a description-only match first", async () => {
99
+ expect((await topNames("recurring cron jobs"))[0]).toBe("scheduling");
100
+ });
101
+
102
+ test("ranks a content-only match first", async () => {
103
+ expect((await topNames("credential bindings"))[0]).toBe("swarm-scripts");
104
+ expect(await topNames("needle-token")).toEqual(["content-search-example"]);
105
+ });
106
+
107
+ test("ranks natural-language queries by relevance instead of requiring every word", async () => {
108
+ expect((await topNames("how do I review a pull request"))[0]).toBe("pr-review-fix-loop");
109
+ expect((await topNames("where to share a document with people"))[0]).toBe("agent-fs");
110
+ });
39
111
 
40
- expect(skills.map((skill) => skill.name)).toContain("call-time-trap-placement");
112
+ test("a query equal to a skill name ranks that skill first", async () => {
113
+ // On bm25 alone schedule-audit outscores scheduling for this query: its
114
+ // name and description both stem to "schedul". The exact-name boost wins.
115
+ expect((await topNames("Scheduling")).slice(0, 2)).toEqual(["scheduling", "schedule-audit"]);
116
+ expect((await topNames("MEMORY"))[0]).toBe("memory");
117
+ expect((await topNames("call-time-trap-placement"))[0]).toBe("call-time-trap-placement");
41
118
  });
42
119
 
43
- test("matches terms that appear only in content", async () => {
44
- const skills = await searchSkills("needle-token");
120
+ test("quotes FTS5 syntax in user text instead of executing it", async () => {
121
+ for (const query of [
122
+ '"',
123
+ "*",
124
+ "NEAR",
125
+ "-",
126
+ 'cron" OR "x',
127
+ "cron NEAR/2 jobs",
128
+ "-cron",
129
+ "name:cron",
130
+ "cron*",
131
+ ]) {
132
+ await expect(searchSkills(query)).resolves.toBeArray();
133
+ }
134
+ expect((await topNames('timezone" OR "x'))[0]).toBe("scheduling");
135
+ expect((await topNames("-timezone"))[0]).toBe("scheduling");
136
+ // As a column filter, name:timezone would match nothing: "timezone" is
137
+ // only in the scheduling skill's content.
138
+ expect((await topNames("name:timezone"))[0]).toBe("scheduling");
139
+ });
45
140
 
46
- expect(skills.map((skill) => skill.name)).toEqual(["content-search-example"]);
141
+ test("excludes disabled skills", async () => {
142
+ expect(await topNames("cron")).not.toContain("disabled-cron-helper");
143
+ expect(await topNames("disabled-cron-helper")).not.toContain("disabled-cron-helper");
47
144
  });
48
145
 
49
- test("returns no results when any token matches nothing", async () => {
50
- const skills = await searchSkills("call-time nonexistent-token");
146
+ test("respects the limit and the includeContent flag", async () => {
147
+ expect(await topNames("the", 2)).toHaveLength(2);
148
+ const slim = await searchSkills("scheduling", 5, false);
149
+ expect(slim[0]?.name).toBe("scheduling");
150
+ expect(slim[0]?.content).toBe("");
151
+ });
51
152
 
52
- expect(skills).toEqual([]);
153
+ test("keeps the substring fallback for partial words", async () => {
154
+ expect(await topNames("needle-tok")).toEqual(["content-search-example"]);
53
155
  });
54
156
 
55
- test("keeps single-word searches working", async () => {
56
- const skills = await searchSkills("call-time");
157
+ test("keeps the index in sync on update and delete", async () => {
158
+ const skill = await createSkill({
159
+ name: "sync-probe",
160
+ description: "Starts with alphaword.",
161
+ content: "",
162
+ scope: "swarm",
163
+ });
164
+ expect(await topNames("alphaword")).toEqual(["sync-probe"]);
165
+
166
+ await updateSkill(skill.id, { description: "Now about betaword." });
167
+ expect(await topNames("betaword")).toEqual(["sync-probe"]);
168
+ expect(await topNames("alphaword")).toEqual([]);
169
+
170
+ await deleteSkill(skill.id);
171
+ expect(await topNames("betaword")).toEqual([]);
172
+ const ftsRows = getDb()
173
+ .query<{ n: number }, [string]>("SELECT count(*) AS n FROM skills_fts WHERE skill_id = ?")
174
+ .get(skill.id);
175
+ expect(ftsRows?.n).toBe(0);
176
+ });
57
177
 
58
- expect(skills.map((skill) => skill.name)).toContain("call-time-trap-placement");
178
+ test("migrated schema carries the sync triggers", () => {
179
+ const triggers = getDb()
180
+ .query<{ name: string }, []>(
181
+ "SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = 'skills' AND name LIKE 'skills_fts_%' ORDER BY name",
182
+ )
183
+ .all()
184
+ .map((row) => row.name);
185
+ expect(triggers).toEqual(["skills_fts_delete", "skills_fts_insert", "skills_fts_update"]);
59
186
  });
60
187
  });
188
+
189
+ test("migration 239 backfills skills that existed before it ran", async () => {
190
+ const db = new Database(":memory:");
191
+ try {
192
+ db.exec(await Bun.file("src/be/migrations/019_skills.sql").text());
193
+ db.run(
194
+ "INSERT INTO skills (id, name, description, content, type, scope, createdAt, lastUpdatedAt) VALUES ('s1', 'legacy-skill', 'Old description', 'old body', 'remote', 'swarm', '2026-01-01', '2026-01-01')",
195
+ );
196
+ db.exec(await Bun.file("src/be/migrations/239_skills_fts.sql").text());
197
+
198
+ const rows = db
199
+ .query<{ skill_id: string }, [string]>(
200
+ "SELECT skill_id FROM skills_fts WHERE skills_fts MATCH ?",
201
+ )
202
+ .all('"body"');
203
+ expect(rows.map((row) => row.skill_id)).toEqual(["s1"]);
204
+
205
+ // Re-running the migration does not duplicate backfilled rows.
206
+ db.exec(await Bun.file("src/be/migrations/239_skills_fts.sql").text());
207
+ expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM skills_fts").get()?.n).toBe(1);
208
+ } finally {
209
+ db.close();
210
+ }
211
+ });