@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
@@ -83,6 +83,8 @@ const makeCtx = (
83
83
  dbThrows?: boolean;
84
84
  /** The caller's agents.isLead; null means no agents row. */
85
85
  callerIsLead?: boolean | null;
86
+ /** user_external_ids x users rows for the mention lookup. */
87
+ addressees?: Record<string, unknown>[];
86
88
  } = {},
87
89
  ) => {
88
90
  const kv: Record<string, unknown> = {};
@@ -99,6 +101,10 @@ const makeCtx = (
99
101
  db_query: async (args: any) => {
100
102
  queries.push(args);
101
103
  if (opts.dbThrows) throw new Error("db down");
104
+ if (String(args.sql).includes("user_external_ids")) {
105
+ const rows = (opts.addressees ?? []).filter((r) => args.params.includes(r.externalId));
106
+ return { success: true, data: { rows } };
107
+ }
102
108
  if (String(args.sql).includes("FROM agents")) {
103
109
  const lead = opts.callerIsLead ?? false;
104
110
  return {
@@ -157,6 +163,8 @@ describe("defaults", () => {
157
163
  timeoutMs: 4000,
158
164
  maxBlocksPerTask: 1,
159
165
  provider: "auto",
166
+ plainMode: "shadow",
167
+ plainThreshold: 0.25,
160
168
  });
161
169
  });
162
170
 
@@ -198,11 +206,17 @@ describe("pass", () => {
198
206
  const call = jevCalls[0]!;
199
207
  expect(call.url).toBe("https://api.typesafe.ai/v1/systemone");
200
208
  expect(call.auth).toBe("Bearer tsk-test");
201
- expect(Object.keys(call.body.questions).sort()).toEqual(["language", "tone", "verbosity"]);
209
+ expect(Object.keys(call.body.questions).sort()).toEqual([
210
+ "language",
211
+ "plain",
212
+ "tone",
213
+ "verbosity",
214
+ ]);
215
+ expect(call.body.state.plain_language_rules).toEqual(mod.PLAIN_RULES);
202
216
  expect(call.body.state.latest_request).toBe("which option did you pick?");
203
217
  });
204
218
 
205
- test("non-Slack tasks, requesters without prefs, non-completions and other agents' tasks skip Jev", async () => {
219
+ test("with plain off: non-Slack tasks, requesters without prefs, non-completions and other agents' tasks skip Jev", async () => {
206
220
  jevScores(0.1);
207
221
  const cases: Array<[any, Row]> = [
208
222
  [completion("x"), taskRow({ source: "mcp", slackThreadTs: null })],
@@ -220,7 +234,7 @@ describe("pass", () => {
220
234
  [completion("x", { taskId: "not-a-uuid" }), taskRow()],
221
235
  ];
222
236
  for (const [event, row] of cases) {
223
- const { ctx } = makeCtx({ mode: "enforce" }, { row });
237
+ const { ctx } = makeCtx({ mode: "enforce", plainMode: "off" }, { row });
224
238
  expect(await handler(event, ctx)).toBeUndefined();
225
239
  }
226
240
  expect(jevCalls).toHaveLength(0);
@@ -264,9 +278,9 @@ describe("pass", () => {
264
278
  expect(queries).toHaveLength(1);
265
279
  });
266
280
 
267
- test("mode off never reads the task", async () => {
281
+ test("mode off and plainMode off never read the task", async () => {
268
282
  jevScores(0.1);
269
- const { ctx, queries } = makeCtx({ mode: "off" });
283
+ const { ctx, queries } = makeCtx({ mode: "off", plainMode: "off" });
270
284
  expect(await handler(completion("x"), ctx)).toBeUndefined();
271
285
  expect(queries).toHaveLength(0);
272
286
  });
@@ -387,13 +401,12 @@ describe("fail open", () => {
387
401
 
388
402
  describe("self-test", () => {
389
403
  test("records both controls without judging real traffic", async () => {
390
- mockJev((body) =>
391
- Response.json({
392
- answers: {
393
- verbosity: { noul: String(body.state.agent_message).startsWith("Great") ? 0.1 : 0.9 },
394
- },
395
- }),
396
- );
404
+ mockJev((body) => {
405
+ const msg = String(body.state.agent_message);
406
+ const bad = msg.startsWith("Great") || msg.startsWith("Prompt cache monitor");
407
+ const [dim] = Object.keys(body.questions);
408
+ return Response.json({ answers: { [dim!]: { noul: bad ? 0.1 : 0.9 } } });
409
+ });
397
410
  const { ctx, kv, queries, counters } = makeCtx();
398
411
  const event = {
399
412
  tool: "store-progress",
@@ -405,9 +418,305 @@ describe("self-test", () => {
405
418
  positiveControlBadScore: 0.1,
406
419
  negativeControlGoodScore: 0.9,
407
420
  controlsOk: true,
421
+ plainPositiveControlBadScore: 0.1,
422
+ plainNegativeControlGoodScore: 0.9,
423
+ plainControlsOk: true,
408
424
  provider: "typesafe",
409
425
  });
426
+ // Two verbosity calls and two plain calls, each asking one question.
427
+ expect(jevCalls.map((c) => Object.keys(c.body.questions)[0]).sort()).toEqual([
428
+ "plain",
429
+ "plain",
430
+ "verbosity",
431
+ "verbosity",
432
+ ]);
410
433
  expect(queries).toHaveLength(0);
411
434
  expect(counters.total).toBeUndefined();
412
435
  });
413
436
  });
437
+
438
+ describe("plain", () => {
439
+ const slackReply = (message: string, extra: Record<string, unknown> = {}) => ({
440
+ tool: "slack-reply",
441
+ args: { taskId: TASK_ID, message, ...extra },
442
+ requestInfo: { callOrigin: "mcp", agentId: "agent-1" },
443
+ });
444
+
445
+ test("a requester without prefs still gets the plain question", async () => {
446
+ jevScores(0.9);
447
+ const { ctx, counters } = makeCtx({}, { row: taskRow({ comms: null }) });
448
+ expect(await handler(completion("Picked B."), ctx)).toBeUndefined();
449
+ expect(Object.keys(jevCalls[0]!.body.questions)).toEqual(["plain"]);
450
+ expect(counters).toMatchObject({ total: 1, pass: 1 });
451
+ });
452
+
453
+ test("mode off with plainMode shadow asks only plain", async () => {
454
+ jevScores(0.9);
455
+ const { ctx } = makeCtx({ mode: "off" });
456
+ await handler(completion("Picked B."), ctx);
457
+ expect(Object.keys(jevCalls[0]!.body.questions)).toEqual(["plain"]);
458
+ });
459
+
460
+ test("plain uses plainThreshold, not threshold", async () => {
461
+ jevScores({ plain: 0.3 });
462
+ const passing = makeCtx({ mode: "enforce", plainMode: "enforce", threshold: 0.75 });
463
+ expect(await handler(completion("Picked B."), passing.ctx)).toBeUndefined();
464
+ expect(passing.counters).toMatchObject({ pass: 1 });
465
+
466
+ jevScores({ plain: 0.2 });
467
+ const failing = makeCtx({ mode: "enforce", plainMode: "enforce", threshold: 0.75 });
468
+ const result = await handler(completion("p50 Δ +120ms (n=37)"), failing.ctx);
469
+ expect(result).toMatchObject({ action: "block" });
470
+ expect(result.reason).toContain("plain (0.20 vs 0.25)");
471
+ });
472
+
473
+ test("plainMode shadow records a plain failure and never blocks, even with mode enforce", async () => {
474
+ jevScores({ plain: 0.05 });
475
+ const { ctx, counters, kv } = makeCtx({ mode: "enforce" });
476
+ expect(await handler(completion("p50 Δ +120ms (n=37)"), ctx)).toBeUndefined();
477
+ expect(counters).toMatchObject({ fail: 1, shadowed: 1, "dim:plain": 1 });
478
+ expect(counters.blocked).toBeUndefined();
479
+ expect(kv.last).toMatchObject({ failed: ["plain"], plainMode: "shadow" });
480
+ });
481
+
482
+ test("plainMode enforce blocks and points the agent at the comms skill", async () => {
483
+ jevScores({ plain: 0.05 });
484
+ const { ctx, counters } = makeCtx({ mode: "shadow", plainMode: "enforce" });
485
+ const result = await handler(completion("p50 Δ +120ms (n=37)"), ctx);
486
+ expect(result).toMatchObject({ action: "block" });
487
+ expect(result.reason).toContain("plain (0.05 vs 0.25)");
488
+ expect(result.reason).toContain("`comms` skill (Simpler mode)");
489
+ expect(result.reason).toContain("Keep every number, name, link and hedge verbatim");
490
+ expect(result.reason).toContain("call store-progress again");
491
+ expect(counters).toMatchObject({ blocked: 1, "dim:plain": 1 });
492
+ });
493
+
494
+ test("a failing preference in shadow is left out of a plain block", async () => {
495
+ jevScores({ plain: 0.05, verbosity: 0.1 });
496
+ const { ctx } = makeCtx({ mode: "shadow", plainMode: "enforce" });
497
+ const result = await handler(completion("x"), ctx);
498
+ expect(result.reason).toContain("plain (");
499
+ expect(result.reason).not.toContain("verbosity (");
500
+ });
501
+
502
+ test("structured JSON output skips the plain question", async () => {
503
+ jevScores(0.9);
504
+ const { ctx } = makeCtx({ mode: "enforce", plainMode: "enforce" });
505
+ await handler(completion('{"prUrl":"https://example.com/pull/1","n":3}'), ctx);
506
+ expect(Object.keys(jevCalls[0]!.body.questions)).not.toContain("plain");
507
+ expect(mod.looksLikeJson("{not json")).toBe(false);
508
+ });
509
+
510
+ test("a JSON message on any Slack tool is still judged plain, even with mode off", async () => {
511
+ const json = '{"p50":"+120ms","n":37}';
512
+ for (const tool of ["slack-reply", "slack-post", "slack-start-thread", "slack-update"]) {
513
+ jevCalls = [];
514
+ jevScores({ plain: 0.05 });
515
+ const { ctx } = makeCtx({ mode: "off", plainMode: "enforce" });
516
+ const result = await handler(
517
+ {
518
+ tool,
519
+ args: { taskId: TASK_ID, channelId: "C1", message: json },
520
+ requestInfo: { callOrigin: "mcp", agentId: "agent-1", sourceTaskId: TASK_ID },
521
+ },
522
+ ctx,
523
+ );
524
+ expect(jevCalls).toHaveLength(1);
525
+ expect(Object.keys(jevCalls[0]!.body.questions)).toEqual(["plain"]);
526
+ expect(result).toMatchObject({ action: "block" });
527
+ }
528
+ });
529
+
530
+ test("slack-reply is judged on its message with the named task", async () => {
531
+ jevScores({ plain: 0.05 });
532
+ const { ctx, queries } = makeCtx({ plainMode: "enforce" });
533
+ const result = await handler(slackReply("p50 Δ +120ms (n=37)"), ctx);
534
+ expect(result).toMatchObject({ action: "block" });
535
+ expect(result.reason).toContain("call slack-reply again");
536
+ expect(queries[0].params).toEqual([TASK_ID]);
537
+ expect(jevCalls[0]!.body.state.agent_message).toBe("p50 Δ +120ms (n=37)");
538
+ });
539
+
540
+ test("slack-post, slack-start-thread and slack-update use the caller's task context", async () => {
541
+ for (const tool of ["slack-post", "slack-start-thread", "slack-update"]) {
542
+ jevCalls = [];
543
+ jevScores(0.9);
544
+ const { ctx, queries } = makeCtx();
545
+ await handler(
546
+ {
547
+ tool,
548
+ args: { channelId: "C1", message: "Deploys take 4 minutes now." },
549
+ requestInfo: { callOrigin: "mcp", agentId: "agent-1", sourceTaskId: TASK_ID },
550
+ },
551
+ ctx,
552
+ );
553
+ expect(queries[0].params).toEqual([TASK_ID]);
554
+ expect(jevCalls).toHaveLength(1);
555
+ }
556
+ });
557
+
558
+ test("a Slack post from a non-Slack task is still judged", async () => {
559
+ jevScores(0.9);
560
+ const { ctx } = makeCtx({}, { row: taskRow({ source: "mcp", slackThreadTs: null }) });
561
+ await handler(slackReply("Deploys take 4 minutes now."), ctx);
562
+ expect(jevCalls).toHaveLength(1);
563
+ });
564
+
565
+ test("a Slack post without a task, or on another agent's task, skips Jev", async () => {
566
+ jevScores(0.05);
567
+ const noTask = makeCtx({ plainMode: "enforce" });
568
+ expect(
569
+ await handler(
570
+ {
571
+ tool: "slack-post",
572
+ args: { channelId: "C1", message: "x" },
573
+ requestInfo: { callOrigin: "mcp", agentId: "agent-1" },
574
+ },
575
+ noTask.ctx,
576
+ ),
577
+ ).toBeUndefined();
578
+ const other = makeCtx({ plainMode: "enforce" }, { row: taskRow({ agentId: "agent-2" }) });
579
+ expect(await handler(slackReply("x"), other.ctx)).toBeUndefined();
580
+ expect(jevCalls).toHaveLength(0);
581
+ });
582
+
583
+ test("a Slack post on an unassigned task the caller did not create skips Jev", async () => {
584
+ // slack-reply itself rejects this task, so nothing from it may reach the judge,
585
+ // even though store-progress may write to an unassigned task.
586
+ jevScores(0.05);
587
+ const unassigned = makeCtx({ plainMode: "enforce" }, { row: taskRow({ agentId: null }) });
588
+ expect(await handler(slackReply("x"), unassigned.ctx)).toBeUndefined();
589
+ const lead = makeCtx(
590
+ { plainMode: "enforce" },
591
+ { row: taskRow({ agentId: "agent-2" }), callerIsLead: true },
592
+ );
593
+ expect(await handler(slackReply("x"), lead.ctx)).toBeUndefined();
594
+ expect(jevCalls).toHaveLength(0);
595
+ });
596
+
597
+ test("the task's creator may post and is judged", async () => {
598
+ jevScores(0.9);
599
+ const { ctx } = makeCtx(
600
+ {},
601
+ { row: taskRow({ agentId: "agent-2", creatorAgentId: "agent-1" }) },
602
+ );
603
+ await handler(slackReply("Deploys take 4 minutes now."), ctx);
604
+ expect(jevCalls).toHaveLength(1);
605
+ });
606
+
607
+ test("store-progress and slack-reply share one block budget per task", async () => {
608
+ jevScores({ plain: 0.05 });
609
+ const { ctx, counters } = makeCtx({ plainMode: "enforce" });
610
+ expect(await handler(slackReply("p50 Δ"), ctx)).toMatchObject({ action: "block" });
611
+ expect(await handler(completion("p50 Δ"), ctx)).toBeUndefined();
612
+ expect(counters).toMatchObject({ blocked: 1, escaped: 1 });
613
+ });
614
+
615
+ test("a script SDK call is recorded and never blocked", async () => {
616
+ jevScores({ plain: 0.05 });
617
+ const { ctx, counters } = makeCtx({ mode: "enforce", plainMode: "enforce" });
618
+ const event = {
619
+ ...slackReply("p50 Δ"),
620
+ requestInfo: { callOrigin: "script-sdk", agentId: "agent-1" },
621
+ };
622
+ expect(await handler(event, ctx)).toBeUndefined();
623
+ expect(counters).toMatchObject({ fail: 1, shadowed: 1 });
624
+ expect(counters.blocked).toBeUndefined();
625
+ });
626
+ });
627
+
628
+ describe("multi-person threads", () => {
629
+ const BOB_PREFS = { language: "Spanish", tone: "warm" };
630
+ const bob = { externalId: "UBOB", id: "user-2", name: "Bob", comms: JSON.stringify(BOB_PREFS) };
631
+ const post = (message: string) => ({
632
+ tool: "slack-reply",
633
+ args: { taskId: TASK_ID, message },
634
+ requestInfo: { callOrigin: "mcp", agentId: "agent-1" },
635
+ });
636
+
637
+ test("mentionedSlackIds keeps first-appearance order and drops duplicates", () => {
638
+ expect(mod.mentionedSlackIds("<@UBOB|bob> and <@U1> then <@UBOB>")).toEqual(["UBOB", "U1"]);
639
+ expect(mod.mentionedSlackIds("no mentions, @bob")).toEqual([]);
640
+ });
641
+
642
+ test("a message that mentions another person is judged against that person's prefs", async () => {
643
+ jevScores({ language: 0.1 });
644
+ const { ctx, kv } = makeCtx({ mode: "enforce" }, { addressees: [bob] });
645
+ const result = await handler(post("<@UBOB> hola, el deploy tarda 4 minutos."), ctx);
646
+ const state = jevCalls[0]!.body.state;
647
+ expect(state.requester).toBe("Bob");
648
+ expect(state.requester_preferences).toEqual(BOB_PREFS);
649
+ expect(Object.keys(jevCalls[0]!.body.questions).sort()).toEqual(["language", "plain", "tone"]);
650
+ expect(result).toMatchObject({ action: "block" });
651
+ expect(result.reason).toContain("Bob's stated preferences");
652
+ expect(kv.last).toMatchObject({
653
+ judgedFor: "mention",
654
+ audienceId: "user-2",
655
+ requester: "Alice",
656
+ });
657
+ });
658
+
659
+ test("the first mentioned person with prefs wins; one without prefs is skipped", async () => {
660
+ jevScores(0.9);
661
+ const carol = { externalId: "UCAROL", id: "user-3", name: "Carol", comms: null };
662
+ const { ctx } = makeCtx({}, { addressees: [carol, bob] });
663
+ await handler(post("<@UCAROL> <@UBOB> deploys take 4 minutes."), ctx);
664
+ expect(jevCalls[0]!.body.state.requester).toBe("Bob");
665
+ });
666
+
667
+ test("no mention, or an unknown one, falls back to the requester", async () => {
668
+ for (const message of ["Deploys take 4 minutes.", "<@UNOBODY> deploys take 4 minutes."]) {
669
+ jevCalls = [];
670
+ jevScores(0.9);
671
+ const { ctx, kv } = makeCtx({}, { addressees: [bob] });
672
+ await handler(post(message), ctx);
673
+ expect(jevCalls[0]!.body.state.requester_preferences).toEqual(PREFS);
674
+ expect(kv.last).toMatchObject({ judgedFor: "requester", audience: "Alice" });
675
+ }
676
+ });
677
+
678
+ test("mentioning the requester keeps the requester's prefs", async () => {
679
+ jevScores(0.9);
680
+ const alice = {
681
+ externalId: "U1",
682
+ id: "user-1",
683
+ name: "Alice",
684
+ comms: JSON.stringify(BOB_PREFS),
685
+ };
686
+ const { ctx } = makeCtx({}, { addressees: [alice] });
687
+ await handler(post("<@U1> deploys take 4 minutes."), ctx);
688
+ expect(jevCalls[0]!.body.state.requester_preferences).toEqual(PREFS);
689
+ });
690
+
691
+ test.each([
692
+ null,
693
+ "{}",
694
+ ])("a self-mention without requester prefs (%s) skips to the next mentioned person with prefs", async (comms) => {
695
+ jevScores(0.9);
696
+ const alice = { externalId: "U1", id: "user-1", name: "Alice", comms };
697
+ const { ctx, kv } = makeCtx(
698
+ { mode: "enforce", plainMode: "off" },
699
+ { row: taskRow({ comms }), addressees: [alice, bob] },
700
+ );
701
+ await handler(post("<@U1> <@UBOB> deploys take 4 minutes."), ctx);
702
+ expect(jevCalls).toHaveLength(1);
703
+ expect(jevCalls[0]!.body.state.requester).toBe("Bob");
704
+ expect(jevCalls[0]!.body.state.requester_preferences).toEqual(BOB_PREFS);
705
+ expect(kv.last).toMatchObject({ judgedFor: "mention", audienceId: "user-2" });
706
+ });
707
+
708
+ test("a completion that mentions someone is judged for them too", async () => {
709
+ jevScores(0.9);
710
+ const { ctx } = makeCtx({}, { addressees: [bob] });
711
+ await handler(completion("<@UBOB> done, PR #12."), ctx);
712
+ expect(jevCalls[0]!.body.state.requester).toBe("Bob");
713
+ });
714
+
715
+ test("with mode off, plain alone skips the mention lookup", async () => {
716
+ jevScores(0.9);
717
+ const { ctx, queries } = makeCtx({ mode: "off" }, { addressees: [bob] });
718
+ await handler(post("<@UBOB> deploys take 4 minutes."), ctx);
719
+ expect(queries.some((q) => String(q.sql).includes("user_external_ids"))).toBe(false);
720
+ expect(jevCalls).toHaveLength(1);
721
+ });
722
+ });
@@ -266,6 +266,39 @@ describe("list-endpoint slimming", () => {
266
266
  }
267
267
  });
268
268
 
269
+ test("getAllTasks — the preview starts after a leading sibling block", async () => {
270
+ // `prependSiblingBlock` output: a long block, a blank line, then the ask.
271
+ const agentId = "preview-sibling-agent";
272
+ const sibling = (n: number) =>
273
+ `- [in_progress] task:${n} — agent:Picateclas — started 2m ago, last active 10s ago\n "${"é".repeat(190)}"`;
274
+ const block = `<sibling_tasks_in_progress>\nThe following tasks are already running in the same context (contextKey: slack:C1:1.2). The user has submitted new input while they were in flight — coordinate with them, do not duplicate:\n\n${[1, 2, 3].map(sibling).join("\n")}\n</sibling_tasks_in_progress>`;
275
+ const ask = `also bump the version ${"v".repeat(400)}`;
276
+ const texts = [
277
+ `${block}\n\n${ask}`,
278
+ `${block}\nno blank line`,
279
+ // No closing tag: nothing to skip.
280
+ `<sibling_tasks_in_progress>\n${"x".repeat(400)}`,
281
+ // Not at the start: nothing to skip.
282
+ `lead in\n\n${block}\n\nask`,
283
+ ];
284
+ for (const text of texts) await createTaskExtended(text, { agentId });
285
+
286
+ const expected = [
287
+ `${ask.slice(0, 300)}…`,
288
+ "\nno blank line",
289
+ `<sibling_tasks_in_progress>\n${"x".repeat(272)}…`,
290
+ `${`lead in\n\n${block}\n\nask`.slice(0, 300)}…`,
291
+ ].sort();
292
+ for (const opts of [{ slim: true }, { fields: "timeline" as const }]) {
293
+ const rows = await getAllTasks({ agentId, limit: 50 }, opts);
294
+ expect(rows.map((t) => t.task).sort()).toEqual(expected);
295
+ }
296
+ // Session roots map the full text, so the JS skip must agree with the SQL one.
297
+ const sessions = await listRecentSessions({ limit: 200, slim: true });
298
+ const roots = sessions.filter((s) => s.root.agentId === agentId).map((s) => s.root.task);
299
+ expect(roots.sort()).toEqual(expected);
300
+ });
301
+
269
302
  test("getAllTasks — slim and full pages hold the same rows in the same order", async () => {
270
303
  for (let i = 0; i < 12; i++) {
271
304
  await createTaskExtended(`page-parity ${i}`, { agentId: "slim-agent-1" });
@@ -0,0 +1,55 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { expect, test } from "bun:test";
3
+
4
+ test("migration 240 hands each legacy child to the attempt that owns it now", async () => {
5
+ const db = new Database(":memory:");
6
+ try {
7
+ db.exec("CREATE TABLE workflow_runs (id TEXT PRIMARY KEY, workflowId TEXT, status TEXT)");
8
+ db.exec(
9
+ `CREATE TABLE workflow_run_steps (
10
+ id TEXT PRIMARY KEY, status TEXT, retryCount INTEGER NOT NULL DEFAULT 0, nextRetryAt TEXT
11
+ )`,
12
+ );
13
+ db.exec(await Bun.file("src/be/migrations/203_workflow_run_parent_step.sql").text());
14
+ const step = db.query(
15
+ "INSERT INTO workflow_run_steps (id, status, retryCount, nextRetryAt) VALUES (?, ?, ?, ?)",
16
+ );
17
+ // Created on attempt 1 (attempt 0 failed before any child existed) and parked.
18
+ step.run("parked-retry", "waiting", 1, null);
19
+ // Its child failed; attempt 2 is queued and must start a new child.
20
+ step.run("queued-retry", "failed", 2, "2030-01-01T00:00:00.000Z");
21
+ step.run("first-attempt", "completed", 0, null);
22
+ step.run("exhausted", "failed", 3, null);
23
+ const run = db.query(
24
+ "INSERT INTO workflow_runs (id, workflowId, status, parentStepId) VALUES (?, 'wf', ?, ?)",
25
+ );
26
+ run.run("child-parked", "waiting", "parked-retry");
27
+ run.run("child-queued", "failed", "queued-retry");
28
+ run.run("child-first", "completed", "first-attempt");
29
+ run.run("child-exhausted", "failed", "exhausted");
30
+ run.run("child-orphan", "completed", "missing-step");
31
+ run.run("top-level", "completed", null);
32
+
33
+ db.exec(await Bun.file("src/be/migrations/240_workflow_run_parent_step_attempt.sql").text());
34
+
35
+ expect(
36
+ db.prepare("SELECT id, parentStepAttempt AS attempt FROM workflow_runs ORDER BY id").all(),
37
+ ).toEqual([
38
+ { id: "child-exhausted", attempt: 3 },
39
+ { id: "child-first", attempt: 0 },
40
+ { id: "child-orphan", attempt: 0 },
41
+ { id: "child-parked", attempt: 1 },
42
+ { id: "child-queued", attempt: 1 },
43
+ { id: "top-level", attempt: 0 },
44
+ ]);
45
+ // One child per attempt: a second child for the parked attempt is rejected,
46
+ // one for the queued attempt is the new invocation.
47
+ const insert = db.query(
48
+ "INSERT INTO workflow_runs (id, workflowId, status, parentStepId, parentStepAttempt) VALUES (?, 'wf', 'running', ?, ?)",
49
+ );
50
+ expect(() => insert.run("dup", "parked-retry", 1)).toThrow();
51
+ insert.run("next", "queued-retry", 2);
52
+ } finally {
53
+ db.close();
54
+ }
55
+ });
@@ -642,6 +642,12 @@ describe("script connections", () => {
642
642
  markMigrationApplied(database, "235_chat_relay_defer.sql");
643
643
  // 237 reads approval_requests and inbox_item_state, which this migration-112-only fixture does not create.
644
644
  markMigrationApplied(database, "237_session_inbox_cleanup.sql");
645
+ // 239 indexes skills, which this migration-112-only fixture does not create.
646
+ markMigrationApplied(database, "239_skills_fts.sql");
647
+ // 240 alters workflow_runs, which this migration-112-only fixture does not create.
648
+ markMigrationApplied(database, "240_workflow_run_parent_step_attempt.sql");
649
+ // 241 indexes skills, which this migration-112-only fixture does not create.
650
+ markMigrationApplied(database, "241_skills_meta_fts.sql");
645
651
 
646
652
  const id = crypto.randomUUID();
647
653
  const now = new Date().toISOString();