@desplega.ai/agent-swarm 2.5.1 → 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 (197) hide show
  1. package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-gr2fcyyj.js} +3 -3
  2. package/dist/{actions-w8xd014k.js → actions-wv766ghf.js} +18 -18
  3. package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-speb906t.js} +3 -3
  4. package/dist/{app-nndcvftq.js → app-ag29n14f.js} +3 -3
  5. package/dist/{assistant-hjmqyscx.js → assistant-3x8bz3eg.js} +19 -19
  6. package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-5b3d2eea.js} +1 -1
  7. package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-jsmfrpzf.js} +1 -1
  8. package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-s73gerxy.js} +2 -2
  9. package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-5zmax2wr.js} +4 -4
  10. package/dist/{claude-adapter-2f8b85df.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-c3hp36ph.js → claude-managed-setup-3k8m3hhc.js} +2 -2
  13. package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-fkbyk66x.js} +6905 -6855
  14. package/dist/{cli-y3zyaee5.js → cli-1fc85mvt.js} +5 -5
  15. package/dist/{cli-v2g93k4v.js → cli-210xd1f4.js} +4 -4
  16. package/dist/{cli-sn1vbtg2.js → cli-25j01dpj.js} +3 -3
  17. package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
  18. package/dist/{cli-r9fy0ddc.js → cli-3ngdk5te.js} +1 -1
  19. package/dist/{cli-z63a269p.js → cli-40jsnrjm.js} +2 -2
  20. package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
  21. package/dist/{cli-qfm6my86.js → cli-502eqv93.js} +1 -1
  22. package/dist/{cli-xd4eqtr2.js → cli-6n8axjv1.js} +1 -1
  23. package/dist/{cli-hadv0725.js → cli-8s5wx3nz.js} +2 -2
  24. package/dist/{cli-z2dv25mf.js → cli-99xgw26r.js} +116 -5
  25. package/dist/{cli-jpxsrxcb.js → cli-9s05422w.js} +1 -1
  26. package/dist/{cli-kcsd1ez8.js → cli-aecdcr77.js} +343 -325
  27. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  28. package/dist/{cli-e9btqw4y.js → cli-ce0ak9ms.js} +18 -13
  29. package/dist/{cli-nj12tn2c.js → cli-e2ar4bp1.js} +71 -20
  30. package/dist/{cli-gwcf52gz.js → cli-e8jyq7d8.js} +11 -7
  31. package/dist/{cli-rngde2jv.js → cli-egjggehm.js} +1 -1
  32. package/dist/{cli-qfjm4gvt.js → cli-ewm3pwbq.js} +2 -2
  33. package/dist/{cli-9mm5ade4.js → cli-fsdprwrg.js} +1 -1
  34. package/dist/{cli-3ee5z0q0.js → cli-fzcn3d87.js} +1 -1
  35. package/dist/{cli-eym3jg7w.js → cli-gpsz7g88.js} +6 -6
  36. package/dist/{cli-cwyafhw1.js → cli-hcpj2xqz.js} +1 -1
  37. package/dist/{cli-j1eghqfm.js → cli-hhxjnjzs.js} +1 -1
  38. package/dist/{cli-pyy4c9ch.js → cli-jjznk3f7.js} +881 -232
  39. package/dist/{cli-q9pzwxvk.js → cli-kxawjrxg.js} +222 -37
  40. package/dist/{cli-rh5smt8t.js → cli-mz9afxxf.js} +14 -3
  41. package/dist/{cli-mz2ygeyq.js → cli-n45mr7zh.js} +2 -2
  42. package/dist/{cli-fes51ya2.js → cli-p0rzjkya.js} +2 -2
  43. package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
  44. package/dist/{cli-mq7kg1b1.js → cli-p9rafdrb.js} +41 -6
  45. package/dist/{cli-cah5detr.js → cli-qgf8emrn.js} +5 -5
  46. package/dist/{cli-2wba1ac9.js → cli-qzme5949.js} +26 -26
  47. package/dist/{cli-znvq0k3t.js → cli-s49hpn37.js} +2 -2
  48. package/dist/{cli-xc65x52q.js → cli-s53vp709.js} +1 -1
  49. package/dist/{cli-0jy85zz4.js → cli-svrj71pg.js} +18 -16
  50. package/dist/{cli-1dp1zj04.js → cli-vwn81jbs.js} +3 -3
  51. package/dist/{cli-ajqhxtnz.js → cli-w1zwctj2.js} +1 -1
  52. package/dist/{cli-22rmcjkg.js → cli-y561h5a9.js} +7 -6
  53. package/dist/{cli-2jg61wab.js → cli-z21whrrg.js} +15 -15
  54. package/dist/cli.js +19 -18
  55. package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-n8ka66vf.js} +2 -2
  56. package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-fsebts37.js} +1 -1
  57. package/dist/{commands-mbvhbrcy.js → commands-t04rt2pc.js} +2 -2
  58. package/dist/{controller-gnq1k1mf.js → controller-rgna04r2.js} +2 -2
  59. package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-bqm1rtft.js} +20 -20
  60. package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-swr2k95y.js} +1 -1
  61. package/dist/{db-88wcymxt.js → db-24ck09xy.js} +1 -1
  62. package/dist/{db-af9y8nkf.js → db-a4m7haw0.js} +3 -3
  63. package/dist/{dispatcher-606ydx5v.js → dispatcher-nwf9ss0q.js} +17 -17
  64. package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-kpdnhjst.js} +2 -2
  65. package/dist/{external-channel-y8q5z8z2.js → external-channel-j1e2p34p.js} +2 -2
  66. package/dist/{grok-adapter-39aha52t.js → grok-adapter-qtjz0whk.js} +4 -4
  67. package/dist/{handlers-rk9dqech.js → handlers-19xr6dnz.js} +20 -20
  68. package/dist/{heartbeat-jfe9zfjr.js → heartbeat-vtesda5d.js} +20 -20
  69. package/dist/{hook-b1mez1m8.js → hook-8wqaszda.js} +1 -1
  70. package/dist/{hook-ffevzed1.js → hook-b2hhj6rd.js} +1 -1
  71. package/dist/{http-d5b44f1r.js → http-mxcqhhcs.js} +3020 -2463
  72. package/dist/{index-5en23s5z.js → index-1r8wfpzs.js} +1 -1
  73. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  74. package/dist/{index-mj46rk9k.js → index-kw2mmat7.js} +20 -20
  75. package/dist/{index-nq8hhv28.js → index-n1vbvta9.js} +19 -10
  76. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  77. package/dist/{index-d2qeem97.js → index-spwxjgay.js} +17 -17
  78. package/dist/{index-4q3vj1yr.js → index-srg522m6.js} +17 -17
  79. package/dist/{keepalive-wsby1btq.js → keepalive-dsd8rtgx.js} +3 -3
  80. package/dist/{lead-paa141j6.js → lead-zyerk1x2.js} +30 -30
  81. package/dist/{maintenance-vnshcyv9.js → maintenance-jqmqwnet.js} +1 -1
  82. package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
  83. package/dist/{onboard-nr4xhpzy.js → onboard-w4yd7s8t.js} +2 -2
  84. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
  85. package/dist/{otel-impl-ytn9wyv9.js → otel-impl-f36byzbd.js} +1 -1
  86. package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-xvak5p4v.js} +3 -3
  87. package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-682k2mt5.js} +12 -2
  88. package/dist/rbac-roles-gfn30fb8.js +26 -0
  89. package/dist/{render-v2-pyfsg9zk.js → render-v2-hgytz8nn.js} +3 -3
  90. package/dist/{resume-phj8xhqr.js → resume-2dvq348g.js} +7 -5
  91. package/dist/{session-access-jzx2z9h5.js → session-access-7f21ekq9.js} +4 -4
  92. package/dist/{setup-rfg0f0sg.js → setup-epffxtbk.js} +1 -1
  93. package/dist/{work-objects-44z2yjg8.js → work-objects-xz1a5rqk.js} +20 -20
  94. package/dist/{worker-zsfqzxhm.js → worker-42t8emjt.js} +30 -30
  95. package/openapi.json +3163 -2114
  96. package/package.json +7 -6
  97. package/src/be/approval-config-question.ts +389 -0
  98. package/src/be/automation-preflight.ts +37 -3
  99. package/src/be/db/session-tree.ts +158 -13
  100. package/src/be/db/session-visibility.ts +31 -0
  101. package/src/be/db/skill-search.ts +87 -0
  102. package/src/be/db/tasks/read.ts +47 -7
  103. package/src/be/db/workflow-runs.ts +54 -7
  104. package/src/be/db.ts +27 -5
  105. package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
  106. package/src/be/migrations/235_chat_relay_defer.sql +86 -0
  107. package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
  108. package/src/be/migrations/239_skills_fts.sql +40 -0
  109. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  110. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  111. package/src/be/rbac-roles.ts +138 -0
  112. package/src/be/scripts/run-credential.ts +64 -0
  113. package/src/be/scripts/run-saved.ts +3 -2
  114. package/src/be/swarm-config-guard.ts +23 -0
  115. package/src/be/users.ts +28 -4
  116. package/src/extensions/catalog.generated.json +4 -4
  117. package/src/http/all-routes.ts +1 -0
  118. package/src/http/approval-requests.ts +254 -43
  119. package/src/http/config.ts +22 -37
  120. package/src/http/index.ts +2 -0
  121. package/src/http/schedules.ts +26 -0
  122. package/src/http/scripts.ts +2 -2
  123. package/src/http/sessions.ts +118 -2
  124. package/src/http/user-roles.ts +173 -0
  125. package/src/http/users.ts +9 -0
  126. package/src/http/x.ts +2 -2
  127. package/src/integrations/chat-sdk/commands.ts +14 -1
  128. package/src/integrations/chat-sdk/inbound.ts +42 -6
  129. package/src/integrations/chat-sdk/outbound.ts +35 -10
  130. package/src/integrations/chat-sdk/reactions.ts +107 -0
  131. package/src/integrations/chat-sdk/relay.ts +75 -7
  132. package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
  133. package/src/integrations/reaction-events.ts +20 -0
  134. package/src/providers/amp-adapter.ts +1 -1
  135. package/src/providers/dsh-adapter.ts +1 -1
  136. package/src/rbac/legacy-policy.ts +3 -0
  137. package/src/rbac/permissions.ts +8 -0
  138. package/src/scheduler/schedule-task.ts +44 -3
  139. package/src/scheduler/scheduler.ts +4 -4
  140. package/src/scripts-runtime/loader.ts +8 -1
  141. package/src/slack/ack.ts +2 -1
  142. package/src/slack/reaction-shortcode.ts +4 -7
  143. package/src/tasks/worker-follow-up.ts +30 -0
  144. package/src/tests/approval-config-question.test.ts +702 -0
  145. package/src/tests/approval-requests-notifications.test.ts +189 -0
  146. package/src/tests/chat-sdk-commands.test.ts +12 -0
  147. package/src/tests/chat-sdk-inbound.test.ts +183 -9
  148. package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
  149. package/src/tests/chat-sdk-relay.test.ts +361 -10
  150. package/src/tests/chat-sdk-tools.test.ts +104 -0
  151. package/src/tests/docs-index-footprint.test.js +3 -3
  152. package/src/tests/dsh-adapter.test.ts +1 -1
  153. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  154. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  155. package/src/tests/http-users.test.ts +38 -0
  156. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  157. package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
  158. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  159. package/src/tests/rbac-engine.test.ts +2 -0
  160. package/src/tests/rbac-roles.test.ts +10 -0
  161. package/src/tests/schedule-http-triage-tools.test.ts +44 -0
  162. package/src/tests/script-api-credential.test.ts +173 -0
  163. package/src/tests/script-connections.test.ts +10 -0
  164. package/src/tests/script-run-config-scope.test.ts +120 -0
  165. package/src/tests/script-run-token.test.ts +75 -0
  166. package/src/tests/scripts-http.test.ts +19 -0
  167. package/src/tests/sessions-tree.test.ts +410 -1
  168. package/src/tests/skill-search-long-content.test.ts +193 -0
  169. package/src/tests/skill-search.test.ts +173 -22
  170. package/src/tests/user-roles-http.test.ts +204 -0
  171. package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
  172. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  173. package/src/tools/chat-reply.ts +2 -0
  174. package/src/tools/request-human-input.ts +27 -5
  175. package/src/tools/schedules/patch-schedule.ts +18 -0
  176. package/src/tools/schedules/update-schedule.ts +18 -0
  177. package/src/types.ts +23 -0
  178. package/src/workflows/approval-notifications.ts +221 -0
  179. package/src/workflows/checkpoint.ts +69 -35
  180. package/src/workflows/engine.ts +234 -87
  181. package/src/workflows/executors/base.ts +6 -0
  182. package/src/workflows/executors/human-in-the-loop.ts +36 -173
  183. package/src/workflows/executors/sub-workflow.ts +45 -16
  184. package/src/workflows/executors/swarm-script.ts +2 -2
  185. package/src/workflows/recovery.ts +63 -1
  186. package/src/workflows/resume.ts +60 -21
  187. package/src/workflows/retry-poller.ts +25 -4
  188. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  189. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  190. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  191. package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
  192. package/templates/skills/code-quality/content.md +1 -1
  193. package/templates/skills/turso-interaction/content.md +1 -1
  194. package/templates/skills/workflow-iterate/content.md +8 -1
  195. package/dist/rbac-roles-ff1ffmec.js +0 -12
  196. /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
  197. /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
@@ -5,6 +5,7 @@ import {
5
5
  closeDb,
6
6
  completeTask,
7
7
  createAgent,
8
+ createScheduledTask,
8
9
  createTaskExtended,
9
10
  failTask,
10
11
  getDbClient,
@@ -19,13 +20,16 @@ import { cleanExtensionTmpRoot, loadExtension } from "../extensions/loader";
19
20
  import { initExtensionPostBridge, teardownExtensionPostBridge } from "../extensions/post-bridge";
20
21
  import { splitChatMessage } from "../integrations/chat-sdk/limits";
21
22
  import {
23
+ formatChatRelayMessages,
22
24
  formatChatRelayText,
23
25
  processPendingChatRelays,
24
26
  RELAY_GIVE_UP_AFTER_MS,
25
27
  stopChatRelayWatcher,
28
+ TELEGRAM_FOLD_CHARS,
26
29
  } from "../integrations/chat-sdk/relay";
30
+ import { createStandaloneScheduleTask } from "../scheduler/schedule-task";
27
31
  import { chatSdkContextKey } from "../tasks/context-key";
28
- import { createResumeFollowUp } from "../tasks/worker-follow-up";
32
+ import { createResumeFollowUp, createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
29
33
  import type { Agent, AgentTask } from "../types";
30
34
  import { startTelegramHarness, type TelegramHarness } from "./chat-sdk-harness";
31
35
  import { loadBundleFixture } from "./fixtures/extensions/load";
@@ -82,13 +86,19 @@ function sentText(body: Record<string, unknown>): string {
82
86
 
83
87
  async function createChatTask(
84
88
  chatId: number,
85
- options: { source?: "chat" | "system"; taskType?: string } = {},
89
+ options: { source?: "chat" | "system"; taskType?: string; askMessageId?: number } = {},
86
90
  ): Promise<AgentTask> {
87
91
  return createTaskExtended(`chat task for ${chatId}`, {
88
92
  agentId: lead.id,
89
93
  source: options.source ?? "chat",
90
94
  taskType: options.taskType,
91
95
  contextKey: chatSdkContextKey(`telegram:${chatId}`),
96
+ ...(options.askMessageId !== undefined
97
+ ? {
98
+ mentionMessageId: `${chatId}:${options.askMessageId}`,
99
+ mentionChannelId: `telegram:${chatId}`,
100
+ }
101
+ : {}),
92
102
  });
93
103
  }
94
104
 
@@ -122,14 +132,45 @@ beforeEach(() => {
122
132
  });
123
133
 
124
134
  describe("chat relay text", () => {
125
- test("status prefix for failed and cancelled tasks, default for an empty completion", () => {
126
- const base = { output: undefined, failureReason: undefined } as unknown as AgentTask;
127
- expect(formatChatRelayText({ ...base, status: "completed" })).toBe("Task completed.");
128
- expect(formatChatRelayText({ ...base, status: "completed", output: "done" })).toBe("done");
129
- expect(formatChatRelayText({ ...base, status: "failed", failureReason: "boom" })).toBe(
130
- "**Task failed.**\n\nboom",
135
+ test("task tag first, status line for failed and cancelled, default for an empty completion", () => {
136
+ const base = {
137
+ id: "a3f2b1c9-0000-4000-8000-000000000000",
138
+ output: undefined,
139
+ failureReason: undefined,
140
+ } as unknown as AgentTask;
141
+ const tag = "#a3f2b1c9 · Coder";
142
+ expect(formatChatRelayText({ ...base, status: "completed" }, "Coder")).toBe(
143
+ `${tag}\n\nTask completed.`,
144
+ );
145
+ expect(formatChatRelayText({ ...base, status: "completed", output: "done" }, "Coder")).toBe(
146
+ `${tag}\n\ndone`,
147
+ );
148
+ expect(formatChatRelayText({ ...base, status: "failed", failureReason: "boom" }, "Coder")).toBe(
149
+ `${tag}\n**Task failed.**\n\nboom`,
150
+ );
151
+ expect(formatChatRelayText({ ...base, status: "cancelled" })).toBe(
152
+ "#a3f2b1c9\n**Task cancelled.**",
131
153
  );
132
- expect(formatChatRelayText({ ...base, status: "cancelled" })).toBe("**Task cancelled.**");
154
+ });
155
+
156
+ test("a long Telegram answer folds into <details>; other adapters and short answers do not", () => {
157
+ const base = { id: "a3f2b1c9-0000-4000-8000-000000000000", status: "completed" };
158
+ const long = `The short answer is yes.\n\n${"detail line\n".repeat(200)}`.trim();
159
+ const task = { ...base, output: long } as unknown as AgentTask;
160
+ expect(long.length).toBeGreaterThan(TELEGRAM_FOLD_CHARS);
161
+
162
+ const [message, ...more] = formatChatRelayMessages(task, "telegram", "Coder");
163
+ expect(more).toHaveLength(0);
164
+ // Tag and first paragraph stay visible; the rest is collapsed.
165
+ expect(message).toStartWith("#a3f2b1c9 · Coder\n\nThe short answer is yes.\n\n<details>");
166
+ expect(message).toContain("<summary>Rest of the answer</summary>");
167
+ expect(message).toEndWith("</details>");
168
+
169
+ expect(formatChatRelayMessages(task, "slack", "Coder")).toEqual([
170
+ formatChatRelayText(task, "Coder"),
171
+ ]);
172
+ const short = { ...base, output: "fine" } as unknown as AgentTask;
173
+ expect(formatChatRelayMessages(short, "telegram")).toEqual(["#a3f2b1c9\n\nfine"]);
133
174
  });
134
175
 
135
176
  test("splitChatMessage keeps every chunk within the limit and loses no text", () => {
@@ -289,7 +330,7 @@ describe("chat relay watcher", () => {
289
330
  adapter: "telegram",
290
331
  threadId: `telegram:${chatId}`,
291
332
  direction: "outbound",
292
- text: "observed by an extension",
333
+ text: `#${task.id.slice(0, 8)} · ChatRelayLead\n\nobserved by an extension`,
293
334
  taskId: task.id,
294
335
  });
295
336
  });
@@ -408,6 +449,316 @@ describe("chat relay watcher", () => {
408
449
  expect((await obligation(resume.id))?.delivered_at).not.toBeNull();
409
450
  });
410
451
 
452
+ test("the answer replies to the ask and starts with the task tag", async () => {
453
+ const chatId = nextChatId;
454
+ const task = await createChatTask(chatId, { askMessageId: 41 });
455
+ await completeTask(task.id, "Here is your answer.");
456
+ await processPendingChatRelays();
457
+
458
+ const [send, ...rest] = sendsTo(chatId);
459
+ expect(rest).toHaveLength(0);
460
+ expect(send?.reply_parameters).toEqual({ message_id: 41, allow_sending_without_reply: true });
461
+ // The line-leading tag is escaped so Telegram does not render it as a heading.
462
+ expect(sentText(send ?? {})).toStartWith(`\\#${task.id.slice(0, 8)} · ChatRelayLead`);
463
+ expect(sentText(send ?? {})).toContain("Here is your answer.");
464
+ });
465
+
466
+ test("the answer turns the ask's reaction into 👍, or 👎 for a failed task", async () => {
467
+ const reactionsTo = (chatId: number) =>
468
+ harness.mock.calls
469
+ .filter((call) => call.method === "setMessageReaction")
470
+ .map((call) => call.body as Record<string, unknown>)
471
+ .filter((body) => String(body.chat_id) === String(chatId));
472
+ const doneChat = nextChatId;
473
+ const done = await createChatTask(doneChat, { askMessageId: 51 });
474
+ await completeTask(done.id, "Done.");
475
+ const failedChat = nextChatId + 2_000;
476
+ const failed = await createChatTask(failedChat, { askMessageId: 52 });
477
+ await failTask(failed.id, "the build broke");
478
+ await processPendingChatRelays();
479
+
480
+ expect(reactionsTo(doneChat)).toEqual([
481
+ expect.objectContaining({ message_id: 51, reaction: [{ type: "emoji", emoji: "👍" }] }),
482
+ ]);
483
+ expect(reactionsTo(failedChat)).toEqual([
484
+ expect.objectContaining({ message_id: 52, reaction: [{ type: "emoji", emoji: "👎" }] }),
485
+ ]);
486
+ });
487
+
488
+ test("a REACTION_INVALID answer never fails the delivery", async () => {
489
+ const chatId = nextChatId;
490
+ const task = await createChatTask(chatId, { askMessageId: 61 });
491
+ await completeTask(task.id, "Still delivered.");
492
+ harness.mock.failReactions(1);
493
+ try {
494
+ await processPendingChatRelays();
495
+ } finally {
496
+ harness.mock.failReactions(0);
497
+ }
498
+ expect(sentText(sendsTo(chatId)[0] ?? {})).toContain("Still delivered.");
499
+ const row = await obligation(task.id);
500
+ expect(row?.delivered_at).not.toBeNull();
501
+ expect(row?.last_error).toBeNull();
502
+ expect(
503
+ harness.mock.calls.some(
504
+ (call) =>
505
+ call.method === "setMessageReaction" &&
506
+ String((call.body as Record<string, unknown>).chat_id) === String(chatId),
507
+ ),
508
+ ).toBe(true);
509
+ });
510
+
511
+ test("a chunked answer anchors only its first message", async () => {
512
+ const chatId = nextChatId;
513
+ const task = await createChatTask(chatId, { askMessageId: 42 });
514
+ const output = Array.from({ length: 1000 }, (_, i) => `line${String(i).padStart(4, "0")}`).join(
515
+ "\n",
516
+ );
517
+ await completeTask(task.id, output);
518
+ await processPendingChatRelays();
519
+
520
+ const sends = sendsTo(chatId);
521
+ expect(sends.length).toBeGreaterThan(1);
522
+ expect(sends[0]?.reply_parameters).toEqual({
523
+ message_id: 42,
524
+ allow_sending_without_reply: true,
525
+ });
526
+ for (const later of sends.slice(1)) expect(later.reply_parameters).toBeUndefined();
527
+ // Every message carries a whole collapsed block.
528
+ for (const body of sends) {
529
+ expect(sentText(body)).toContain("<details>");
530
+ expect(sentText(body)).toEndWith("</details>");
531
+ expect(sentText(body).length).toBeLessThanOrEqual(4096);
532
+ }
533
+ });
534
+
535
+ test("a deferred task posts nothing; its wake-up answers the original ask", async () => {
536
+ const chatId = nextChatId;
537
+ const deferred = await createChatTask(chatId, { askMessageId: 43 });
538
+ await completeTask(deferred.id, "Deferred until 18:00.", {
539
+ addTags: ["deferred"],
540
+ deferredAt: new Date().toISOString(),
541
+ });
542
+ expect(await obligation(deferred.id)).toBeNull();
543
+
544
+ // What defer-task books; the scheduler fires it later.
545
+ const schedule = await createScheduledTask({
546
+ name: `deferred-${deferred.id.slice(0, 8)}-${crypto.randomUUID()}`,
547
+ taskTemplate: "Check the deploy and answer.",
548
+ targetType: "agent-task",
549
+ scheduleType: "one_time",
550
+ nextRunAt: new Date().toISOString(),
551
+ targetAgentId: lead.id,
552
+ createdByAgentId: lead.id,
553
+ taskType: "deferred",
554
+ tags: ["deferred"],
555
+ parentTaskId: deferred.id,
556
+ });
557
+ const wakeUp = await createStandaloneScheduleTask(schedule);
558
+ expect(wakeUp.contextKey).toBe(deferred.contextKey);
559
+ expect(wakeUp.mentionMessageId).toBe(`${chatId}:43`);
560
+ await completeTask(wakeUp.id, "The deploy is live.");
561
+
562
+ await processPendingChatRelays();
563
+ const sends = sendsTo(chatId);
564
+ expect(sends).toHaveLength(1);
565
+ expect(sentText(sends[0] ?? {})).toContain("The deploy is live.");
566
+ expect(sentText(sends[0] ?? {})).not.toContain("Deferred until");
567
+ expect(sends[0]?.reply_parameters).toEqual({
568
+ message_id: 43,
569
+ allow_sending_without_reply: true,
570
+ });
571
+ });
572
+
573
+ test("a wake-up of a task outside chat keeps its schedule contextKey", async () => {
574
+ const parent = await createTaskExtended("not a chat task", { agentId: lead.id });
575
+ const schedule = await createScheduledTask({
576
+ name: `deferred-${parent.id.slice(0, 8)}-${crypto.randomUUID()}`,
577
+ taskTemplate: "wake up",
578
+ targetType: "agent-task",
579
+ scheduleType: "one_time",
580
+ nextRunAt: new Date().toISOString(),
581
+ targetAgentId: lead.id,
582
+ createdByAgentId: lead.id,
583
+ taskType: "deferred",
584
+ parentTaskId: parent.id,
585
+ });
586
+ const wakeUp = await createStandaloneScheduleTask(schedule);
587
+ expect(wakeUp.contextKey).toBe(`task:schedule:${schedule.id}`);
588
+ });
589
+
590
+ describe("a delegated worker's deferral", () => {
591
+ let worker: Agent;
592
+ let otherWorker: Agent;
593
+
594
+ beforeAll(async () => {
595
+ worker = await createAgent({
596
+ id: crypto.randomUUID(),
597
+ name: "ChatRelayDeferringWorker",
598
+ isLead: false,
599
+ status: "idle",
600
+ maxTasks: 5,
601
+ });
602
+ otherWorker = await createAgent({
603
+ id: crypto.randomUUID(),
604
+ name: "ChatRelayRetargetWorker",
605
+ isLead: false,
606
+ status: "idle",
607
+ maxTasks: 5,
608
+ });
609
+ });
610
+
611
+ /** A Lead chat ask (anchored on message 43) delegated to `worker`. */
612
+ async function delegate(
613
+ chatId: number,
614
+ ): Promise<{ leadTask: AgentTask; delegated: AgentTask }> {
615
+ const leadTask = await createChatTask(chatId, { askMessageId: 43 });
616
+ const delegated = await createTaskExtended("worker part", {
617
+ agentId: worker.id,
618
+ creatorAgentId: lead.id,
619
+ source: "mcp",
620
+ parentTaskId: leadTask.id,
621
+ });
622
+ expect(delegated.contextKey).toBe(leadTask.contextKey);
623
+ return { leadTask, delegated };
624
+ }
625
+
626
+ /** What defer-task books for `task`, fired by the scheduler. */
627
+ async function deferAndWake(
628
+ task: AgentTask,
629
+ options: { targetAgentId?: string } = {},
630
+ ): Promise<AgentTask> {
631
+ await completeTask(task.id, "Checking back later.", {
632
+ deferredAt: new Date().toISOString(),
633
+ });
634
+ const schedule = await createScheduledTask({
635
+ name: `deferred-${task.id.slice(0, 8)}-${crypto.randomUUID()}`,
636
+ taskTemplate: "resume the worker part",
637
+ targetType: "agent-task",
638
+ scheduleType: "one_time",
639
+ nextRunAt: new Date().toISOString(),
640
+ targetAgentId: options.targetAgentId ?? task.agentId ?? undefined,
641
+ createdByAgentId: task.agentId ?? undefined,
642
+ taskType: "deferred",
643
+ parentTaskId: task.id,
644
+ });
645
+ const wakeUp = await createStandaloneScheduleTask(schedule);
646
+ // The worker reports through the Lead, as its delegated task did.
647
+ expect(wakeUp.contextKey).toBe(`task:schedule:${schedule.id}`);
648
+ return wakeUp;
649
+ }
650
+
651
+ /**
652
+ * Completes the worker's `task` with raw output, then runs its Lead
653
+ * follow-up to a reviewed answer. Returns what reached the chat.
654
+ */
655
+ async function reportThroughLead(
656
+ task: AgentTask,
657
+ chatId: number,
658
+ leadTask: AgentTask,
659
+ ): Promise<Array<Record<string, unknown>>> {
660
+ const completed = await completeTask(task.id, "RAW PRIVATE WORKER RESULT");
661
+ expect(completed).not.toBeNull();
662
+ expect(await obligation(task.id)).toBeNull();
663
+ const followUp = await createWorkerTaskFollowUp({
664
+ task: completed!,
665
+ status: "completed",
666
+ output: completed!.output,
667
+ });
668
+ await processPendingChatRelays();
669
+ expect(sendsTo(chatId)).toHaveLength(0);
670
+
671
+ expect(followUp?.agentId).toBe(lead.id);
672
+ expect(followUp?.contextKey).toBe(leadTask.contextKey);
673
+ expect(followUp?.mentionMessageId).toBe(`${chatId}:43`);
674
+ await completeTask(followUp!.id, "REVIEWED LEAD ANSWER");
675
+ await processPendingChatRelays();
676
+ return sendsTo(chatId);
677
+ }
678
+
679
+ function expectOneReviewedAnswer(sends: Array<Record<string, unknown>>): void {
680
+ expect(sends).toHaveLength(1);
681
+ expect(sentText(sends[0] ?? {})).toContain("REVIEWED LEAD ANSWER");
682
+ expect(sentText(sends[0] ?? {})).not.toContain("RAW PRIVATE WORKER RESULT");
683
+ expect(sends[0]?.reply_parameters).toEqual({
684
+ message_id: 43,
685
+ allow_sending_without_reply: true,
686
+ });
687
+ }
688
+
689
+ test("control: an undeferred worker reports one reviewed answer", async () => {
690
+ const chatId = nextChatId;
691
+ const { leadTask, delegated } = await delegate(chatId);
692
+ expectOneReviewedAnswer(await reportThroughLead(delegated, chatId, leadTask));
693
+ });
694
+
695
+ test("wakes up outside the chat, stays silent and reports through the Lead", async () => {
696
+ const chatId = nextChatId;
697
+ const { leadTask, delegated } = await delegate(chatId);
698
+ const wakeUp = await deferAndWake(delegated);
699
+ expectOneReviewedAnswer(await reportThroughLead(wakeUp, chatId, leadTask));
700
+ });
701
+
702
+ test("a repeated deferral still reports through the Lead", async () => {
703
+ const chatId = nextChatId;
704
+ const { leadTask, delegated } = await delegate(chatId);
705
+ const firstWake = await deferAndWake(delegated);
706
+ const secondWake = await deferAndWake(firstWake);
707
+ expect(await obligation(firstWake.id)).toBeNull();
708
+ expectOneReviewedAnswer(await reportThroughLead(secondWake, chatId, leadTask));
709
+ });
710
+
711
+ test("a resume of the wake-up still reports through the Lead", async () => {
712
+ const chatId = nextChatId;
713
+ const { leadTask, delegated } = await delegate(chatId);
714
+ const wakeUp = await deferAndWake(delegated);
715
+ await startTask(wakeUp.id);
716
+ await supersedeTask(wakeUp.id, { reason: "graceful_shutdown", resumeTaskId: null });
717
+ const result = await createResumeFollowUp({
718
+ parentId: wakeUp.id,
719
+ reason: "graceful_shutdown",
720
+ });
721
+ expect(result.kind).toBe("created");
722
+ if (result.kind !== "created") return;
723
+ expect(result.task.agentId).toBe(worker.id);
724
+ expect(result.task.contextKey).toBe(wakeUp.contextKey);
725
+ expectOneReviewedAnswer(await reportThroughLead(result.task, chatId, leadTask));
726
+ });
727
+
728
+ test("a wake-up re-targeted to another worker reports through the Lead", async () => {
729
+ const chatId = nextChatId;
730
+ const { leadTask, delegated } = await delegate(chatId);
731
+ const wakeUp = await deferAndWake(delegated, { targetAgentId: otherWorker.id });
732
+ expect(wakeUp.agentId).toBe(otherWorker.id);
733
+ expectOneReviewedAnswer(await reportThroughLead(wakeUp, chatId, leadTask));
734
+ });
735
+ });
736
+
737
+ test("a deferred wake-up that runs as another agent than its parent is not relayed", async () => {
738
+ const other = await createAgent({
739
+ id: crypto.randomUUID(),
740
+ name: "ChatRelayOtherAgent",
741
+ isLead: false,
742
+ status: "idle",
743
+ });
744
+ const chatId = nextChatId;
745
+ const parent = await createChatTask(chatId);
746
+ await completeTask(parent.id, "Deferred.", { deferredAt: new Date().toISOString() });
747
+ // Bypasses the scheduler's check: the triggers hold the same line.
748
+ const wakeUp = await createTaskExtended("not the deferring agent", {
749
+ agentId: other.id,
750
+ source: "schedule",
751
+ taskType: "deferred",
752
+ parentTaskId: parent.id,
753
+ contextKey: parent.contextKey,
754
+ });
755
+ await completeTask(wakeUp.id, "OTHER AGENT OUTPUT");
756
+
757
+ expect(await obligation(wakeUp.id)).toBeNull();
758
+ await processPendingChatRelays();
759
+ expect(sendsTo(chatId)).toHaveLength(0);
760
+ });
761
+
411
762
  test("delegated worker tasks and their resumes stay silent", async () => {
412
763
  const worker = await createAgent({
413
764
  id: crypto.randomUUID(),
@@ -3,14 +3,18 @@ import { unlink } from "node:fs/promises";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import {
5
5
  closeDb,
6
+ completeTask,
6
7
  createAgent,
8
+ createScheduledTask,
7
9
  createTaskExtended,
8
10
  getDbClient,
9
11
  getKv,
12
+ getScheduledTaskById,
10
13
  getTaskById,
11
14
  initDb,
12
15
  } from "../be/db";
13
16
  import { CHAT_CHANNELS_NAMESPACE } from "../integrations/chat-sdk/router";
17
+ import { createStandaloneScheduleTask } from "../scheduler/schedule-task";
14
18
  import { createServer } from "../server";
15
19
  import { chatSdkContextKey } from "../tasks/context-key";
16
20
  import { registerChatOpenDmTool } from "../tools/chat-open-dm";
@@ -21,6 +25,8 @@ import {
21
25
  registerRegisterChatChannelTool,
22
26
  registerUnregisterChatChannelTool,
23
27
  } from "../tools/register-chat-channel";
28
+ import { registerPatchScheduleTool } from "../tools/schedules/patch-schedule";
29
+ import { registerUpdateScheduleTool } from "../tools/schedules/update-schedule";
24
30
  import { registerSendTaskTool } from "../tools/send-task";
25
31
  import type { Agent } from "../types";
26
32
  import { startTelegramHarness, type TelegramHarness } from "./chat-sdk-harness";
@@ -108,6 +114,8 @@ beforeAll(async () => {
108
114
  registerRegisterChatChannelTool(server);
109
115
  registerUnregisterChatChannelTool(server);
110
116
  registerSendTaskTool(server);
117
+ registerPatchScheduleTool(server);
118
+ registerUpdateScheduleTool(server);
111
119
  });
112
120
 
113
121
  afterAll(async () => {
@@ -194,6 +202,8 @@ describe("chat-reply", () => {
194
202
  agentId: worker.id,
195
203
  source: "chat",
196
204
  contextKey: chatSdkContextKey("telegram:991002"),
205
+ mentionMessageId: "991002:77",
206
+ mentionChannelId: "telegram:991002",
197
207
  });
198
208
  const result = await callTool(
199
209
  "chat-reply",
@@ -212,6 +222,11 @@ describe("chat-reply", () => {
212
222
  String((call.body as { chat_id?: unknown }).chat_id) === "991002",
213
223
  );
214
224
  expect(sends).toHaveLength(1);
225
+ // Anchored like the final answer: a reply to the message that started the task.
226
+ expect(sends[0]?.body.reply_parameters).toEqual({
227
+ message_id: 77,
228
+ allow_sending_without_reply: true,
229
+ });
215
230
  });
216
231
  });
217
232
 
@@ -518,3 +533,92 @@ describe("chat thread binding through send-task", () => {
518
533
  expect(child?.contextKey).toBe(chatSdkContextKey("telegram:444444"));
519
534
  });
520
535
  });
536
+
537
+ describe("defer-task wake-ups and chat thread access", () => {
538
+ let harness: TelegramHarness;
539
+
540
+ beforeAll(async () => {
541
+ harness = await startTelegramHarness();
542
+ });
543
+
544
+ afterAll(async () => {
545
+ await harness.stop();
546
+ });
547
+
548
+ /** A lead chat task deferred with defer-task: the task is done, its wake-up booked. */
549
+ async function leadDeferral(chatId: string) {
550
+ const parent = await createTaskExtended("human ask", {
551
+ agentId: lead.id,
552
+ source: "chat",
553
+ contextKey: chatSdkContextKey(`telegram:${chatId}`),
554
+ mentionMessageId: `${chatId}:55`,
555
+ });
556
+ await completeTask(parent.id, "Deferred.", { deferredAt: new Date().toISOString() });
557
+ const schedule = await createScheduledTask({
558
+ name: `deferred-${parent.id.slice(0, 8)}-${crypto.randomUUID()}`,
559
+ taskTemplate: "finish the original ask",
560
+ targetType: "agent-task",
561
+ scheduleType: "one_time",
562
+ nextRunAt: new Date(Date.now() + 60_000).toISOString(),
563
+ targetAgentId: lead.id,
564
+ createdByAgentId: lead.id,
565
+ taskType: "deferred",
566
+ parentTaskId: parent.id,
567
+ });
568
+ return { parent, schedule };
569
+ }
570
+
571
+ test("another worker cannot change a lead's deferral", async () => {
572
+ const { schedule } = await leadDeferral("992881");
573
+ for (const tool of ["patch-schedule", "update-schedule"]) {
574
+ const result = await callTool(
575
+ tool,
576
+ { "x-agent-id": worker.id },
577
+ { scheduleId: schedule.id, targetAgentId: worker.id, taskTemplate: "emit ATTACKER OUTPUT" },
578
+ );
579
+ expect(result.structuredContent.success).toBe(false);
580
+ }
581
+ const after = await getScheduledTaskById(schedule.id);
582
+ expect(after?.targetAgentId).toBe(lead.id);
583
+ expect(after?.taskTemplate).toBe("finish the original ask");
584
+ });
585
+
586
+ test("a deferral re-targeted to another agent wakes up outside the chat thread", async () => {
587
+ const chatId = "992882";
588
+ const { schedule } = await leadDeferral(chatId);
589
+ // A lead may re-target it; the new assignee still gets no thread access.
590
+ const patched = await callTool(
591
+ "patch-schedule",
592
+ { "x-agent-id": lead.id },
593
+ { scheduleId: schedule.id, targetAgentId: worker.id },
594
+ );
595
+ expect(patched.structuredContent.success).toBe(true);
596
+ const retargeted = await getScheduledTaskById(schedule.id);
597
+ if (!retargeted) throw new Error("schedule vanished");
598
+
599
+ const wakeUp = await createStandaloneScheduleTask(retargeted);
600
+ expect(wakeUp.agentId).toBe(worker.id);
601
+ expect(wakeUp.contextKey).toBe(`task:schedule:${schedule.id}`);
602
+
603
+ const reply = await callTool(
604
+ "chat-reply",
605
+ { "x-agent-id": worker.id, "x-source-task-id": wakeUp.id },
606
+ { text: "ATTACKER DIRECT REPLY" },
607
+ );
608
+ expect(reply.structuredContent.success).toBe(false);
609
+
610
+ await completeTask(wakeUp.id, "ATTACKER OUTPUT");
611
+ const obligation = await getDbClient().get(
612
+ "SELECT task_id FROM chat_relay_obligations WHERE task_id = ?",
613
+ [wakeUp.id],
614
+ );
615
+ expect(obligation).toBeNull();
616
+ expect(
617
+ harness.mock.calls.some(
618
+ (call) =>
619
+ /^send(Rich)?Message$/.test(call.method) &&
620
+ String((call.body as { chat_id?: unknown }).chat_id) === chatId,
621
+ ),
622
+ ).toBe(false);
623
+ });
624
+ });
@@ -78,7 +78,7 @@ describe("docs API index footprint", () => {
78
78
  expect(tasks).toContain('"path":"/api/task-templates","method":"get"');
79
79
  });
80
80
 
81
- test("OpenAPI and generated pages keep all 444 operations", async () => {
81
+ test("OpenAPI and generated pages keep all 449 operations", async () => {
82
82
  const spec = await Bun.file(resolve(repoRoot, "openapi.json")).json();
83
83
  const operationKeys = [];
84
84
  const tags = new Set();
@@ -103,8 +103,8 @@ describe("docs API index footprint", () => {
103
103
  expect(spec.paths["/api/agents/{id}/runtime"]?.get).toBeDefined();
104
104
 
105
105
  const generatedKeys = await generatedOperationKeys();
106
- expect(operationKeys).toHaveLength(445);
107
- expect(generatedKeys).toHaveLength(445);
106
+ expect(operationKeys).toHaveLength(449);
107
+ expect(generatedKeys).toHaveLength(449);
108
108
  expect(new Set(generatedKeys)).toEqual(new Set(operationKeys));
109
109
  });
110
110
 
@@ -376,7 +376,7 @@ describe("dsh harness", () => {
376
376
  await rename(join(config.cwd, "dsh"), join(config.cwd, "npx"));
377
377
  config.env = { ...config.env, DSH_BINARY: "", PATH: config.cwd };
378
378
  await expect(new DshAdapter().createSession(config)).rejects.toThrow(
379
- "dsh CLI not found. Install @deepseek-ai/dsh@0.2.1-rc.2",
379
+ "dsh CLI not found. Install @deepseek-ai/dsh@0.2.1-rc.3",
380
380
  );
381
381
  expect(await Bun.file(join(config.cwd, "invocation.json")).exists()).toBe(false);
382
382
  });