@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
@@ -0,0 +1,189 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { createServer as createHttpServer, type Server } from "node:http";
4
+
5
+ const postMessage = mock(async (_args: Record<string, unknown>) => ({ ts: "1700000000.000100" }));
6
+ const conversationsInfo = mock(async (_args: { channel: string }) => ({
7
+ channel: { is_ext_shared: false },
8
+ }));
9
+ mock.module("../slack/app", () => ({
10
+ getSlackApp: () => ({
11
+ client: { chat: { postMessage }, conversations: { info: conversationsInfo } },
12
+ }),
13
+ }));
14
+
15
+ import {
16
+ closeDb,
17
+ createAgent,
18
+ getApprovalRequestById,
19
+ initDb,
20
+ listApprovalRequests,
21
+ } from "../be/db";
22
+ import { handleApprovalRequests } from "../http/approval-requests";
23
+ import { resetExternalChannelStateForTesting } from "../slack/external-channel";
24
+ import { listenOnFreePort } from "./test-net";
25
+
26
+ const TEST_DB_PATH = "./test-approval-requests-notifications.sqlite";
27
+
28
+ let server: Server;
29
+ let baseUrl = "";
30
+ let leadId = "";
31
+ let workerId = "";
32
+
33
+ function postRequest(body: Record<string, unknown>, agentId: string | null): Promise<Response> {
34
+ return fetch(`${baseUrl}/api/approval-requests`, {
35
+ method: "POST",
36
+ headers: {
37
+ "Content-Type": "application/json",
38
+ ...(agentId ? { "X-Agent-ID": agentId } : {}),
39
+ },
40
+ body: JSON.stringify({
41
+ title: "Ship it?",
42
+ questions: [{ id: "q1", type: "approval", label: "Approve the deploy?" }],
43
+ approvers: { policy: "any" },
44
+ ...body,
45
+ }),
46
+ });
47
+ }
48
+
49
+ async function createRequest(body: Record<string, unknown>, agentId = leadId): Promise<string> {
50
+ const res = await postRequest(body, agentId);
51
+ expect(res.status).toBe(201);
52
+ const json = (await res.json()) as { approvalRequest: { id: string } };
53
+ return json.approvalRequest.id;
54
+ }
55
+
56
+ async function waitFor(check: () => Promise<boolean>): Promise<void> {
57
+ for (let i = 0; i < 100; i++) {
58
+ if (await check()) return;
59
+ await Bun.sleep(10);
60
+ }
61
+ }
62
+
63
+ describe("POST /api/approval-requests notifications", () => {
64
+ beforeAll(async () => {
65
+ try {
66
+ await unlink(TEST_DB_PATH);
67
+ } catch {}
68
+ initDb(TEST_DB_PATH);
69
+ leadId = (await createAgent({ name: "notify-lead", isLead: true, status: "idle" })).id;
70
+ workerId = (await createAgent({ name: "notify-worker", isLead: false, status: "idle" })).id;
71
+ server = createHttpServer(async (req, res) => {
72
+ const url = new URL(req.url ?? "/", "http://localhost");
73
+ const handled = await handleApprovalRequests(
74
+ req,
75
+ res,
76
+ url.pathname.split("/").filter(Boolean),
77
+ url.searchParams,
78
+ );
79
+ if (!handled) {
80
+ res.writeHead(404);
81
+ res.end();
82
+ }
83
+ });
84
+ baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await new Promise<void>((resolve) => server.close(() => resolve()));
89
+ closeDb();
90
+ for (const suffix of ["", "-wal", "-shm"]) {
91
+ try {
92
+ await unlink(`${TEST_DB_PATH}${suffix}`);
93
+ } catch {}
94
+ }
95
+ });
96
+
97
+ beforeEach(() => {
98
+ postMessage.mockClear();
99
+ conversationsInfo.mockClear();
100
+ resetExternalChannelStateForTesting();
101
+ delete process.env.SLACK_BLOCK_EXTERNAL_CHANNELS;
102
+ });
103
+
104
+ test("posts one Slack card and stores its messageTs", async () => {
105
+ const id = await createRequest({
106
+ title: "Ship <!channel> & go?",
107
+ timeoutSeconds: 3600,
108
+ notifications: [{ channel: "slack", target: "C123" }],
109
+ });
110
+
111
+ await waitFor(async () => {
112
+ const row = await getApprovalRequestById(id);
113
+ const channels = row?.notificationChannels as Array<{ messageTs?: string }> | null;
114
+ return Boolean(channels?.[0]?.messageTs);
115
+ });
116
+
117
+ expect(postMessage).toHaveBeenCalledTimes(1);
118
+ const args = postMessage.mock.calls[0]![0];
119
+ expect(args.channel).toBe("C123");
120
+ expect(args.text).toContain(`/approval-requests/${id}`);
121
+ const blocks = JSON.stringify(args.blocks);
122
+ expect(blocks).toContain("Approval Required: Ship &lt;!channel&gt; &amp; go?");
123
+ expect(blocks).not.toContain("<!channel>");
124
+ expect(blocks).toContain("Approve the deploy?");
125
+ expect(blocks).toContain("Review & Respond");
126
+ expect(blocks).toContain("times out if not responded");
127
+
128
+ const row = await getApprovalRequestById(id);
129
+ expect(row?.notificationChannels).toEqual([
130
+ { channel: "slack", target: "C123", messageTs: "1700000000.000100" },
131
+ ]);
132
+ });
133
+
134
+ test("posts nothing without notifications", async () => {
135
+ const id = await createRequest({});
136
+ await Bun.sleep(50);
137
+
138
+ expect(postMessage).not.toHaveBeenCalled();
139
+ expect((await getApprovalRequestById(id))?.notificationChannels).toBeNull();
140
+ });
141
+
142
+ test("skips email targets without posting", async () => {
143
+ const id = await createRequest({
144
+ notifications: [{ channel: "email", target: "ops@example.com" }],
145
+ });
146
+ await Bun.sleep(50);
147
+
148
+ expect(postMessage).not.toHaveBeenCalled();
149
+ expect((await getApprovalRequestById(id))?.notificationChannels).toEqual([
150
+ { channel: "email", target: "ops@example.com" },
151
+ ]);
152
+ });
153
+
154
+ test("refuses Slack notifications from a non-lead caller and creates nothing", async () => {
155
+ for (const agentId of [workerId, null]) {
156
+ const before = (await listApprovalRequests()).length;
157
+ const res = await postRequest(
158
+ { notifications: [{ channel: "slack", target: "C123" }] },
159
+ agentId,
160
+ );
161
+
162
+ expect(res.status).toBe(403);
163
+ expect((await listApprovalRequests()).length).toBe(before);
164
+ }
165
+ expect(postMessage).not.toHaveBeenCalled();
166
+ });
167
+
168
+ test("lets a non-lead caller create a request without Slack notifications", async () => {
169
+ await createRequest(
170
+ { notifications: [{ channel: "email", target: "ops@example.com" }] },
171
+ workerId,
172
+ );
173
+ });
174
+
175
+ test("skips a channel shared with another org when the external-channel block is on", async () => {
176
+ process.env.SLACK_BLOCK_EXTERNAL_CHANNELS = "true";
177
+ conversationsInfo.mockImplementationOnce(async () => ({ channel: { is_ext_shared: true } }));
178
+
179
+ const id = await createRequest({ notifications: [{ channel: "slack", target: "CEXT" }] });
180
+ await waitFor(async () => conversationsInfo.mock.calls.length > 0);
181
+ await Bun.sleep(50);
182
+
183
+ expect(conversationsInfo).toHaveBeenCalledWith({ channel: "CEXT" });
184
+ expect(postMessage).not.toHaveBeenCalled();
185
+ expect((await getApprovalRequestById(id))?.notificationChannels).toEqual([
186
+ { channel: "slack", target: "CEXT" },
187
+ ]);
188
+ });
189
+ });
@@ -84,6 +84,18 @@ describe("Chat SDK slash commands", () => {
84
84
  expect(await chatTasks(3001)).toHaveLength(0);
85
85
  });
86
86
 
87
+ test("/start in a fresh DM answers with the welcome and stays out of the / menu", async () => {
88
+ await postTelegramUpdate(commandUpdate(dm(3003), "/start"));
89
+ const texts = sentTexts(3003);
90
+ expect(texts).toHaveLength(1);
91
+ expect(texts[0]).toContain("Each answer arrives as a reply to your message.");
92
+ expect(texts[0]).toContain("/help");
93
+ expect(texts[0]).not.toContain("Unknown command");
94
+ expect(CHAT_COMMANDS.map((c) => c.command)).not.toContain("start");
95
+ await flushChatBuffers();
96
+ expect(await chatTasks(3003)).toHaveLength(0);
97
+ });
98
+
87
99
  test("an unknown command gets the list instead of silence", async () => {
88
100
  await postTelegramUpdate(commandUpdate(dm(3002), "/deploy"));
89
101
  const texts = sentTexts(3002);
@@ -7,6 +7,7 @@ import {
7
7
  closeDb,
8
8
  completeTask,
9
9
  createAgent,
10
+ createScheduledTask,
10
11
  createTaskExtended,
11
12
  createUser,
12
13
  getDbClient,
@@ -20,10 +21,12 @@ import {
20
21
  import { linkIdentity } from "../be/users";
21
22
  import { resetFileStorageProviderForTests } from "../fs/registry";
22
23
  import { drainBackground } from "../integrations/chat-sdk/background";
23
- import { formatChatRelayText } from "../integrations/chat-sdk/relay";
24
+ import { CHAT_CHANNEL_SETTINGS_NAMESPACE } from "../integrations/chat-sdk/reactions";
25
+ import { formatChatRelayText, formatChatTaskTag } from "../integrations/chat-sdk/relay";
24
26
  import { CHAT_CHANNELS_NAMESPACE, routeChatMessage } from "../integrations/chat-sdk/router";
25
27
  import { getChatInstance } from "../integrations/chat-sdk/runtime";
26
28
  import { isChatThreadBuffered } from "../integrations/chat-sdk/thread-buffer";
29
+ import { createStandaloneScheduleTask } from "../scheduler/schedule-task";
27
30
  import { chatSdkContextKey } from "../tasks/context-key";
28
31
  import { registerDeferTaskTool } from "../tools/defer-task";
29
32
  import type { Agent } from "../types";
@@ -144,6 +147,25 @@ function buildDeferTaskTool(): DeferTaskTool {
144
147
  const dm = (id: number): TelegramChat => ({ id, type: "private" });
145
148
  const group = (id: number): TelegramChat => ({ id, type: "group" });
146
149
 
150
+ const messageIdOf = (update: Record<string, unknown>) =>
151
+ (update.message as { message_id: number }).message_id;
152
+
153
+ /** Every `setMessageReaction` call on one message, as the emoji it set (`""` = cleared). */
154
+ function reactionsOn(chatId: number, update: Record<string, unknown>): string[] {
155
+ return harness.mock.calls
156
+ .filter((call) => call.method === "setMessageReaction")
157
+ .map((call) => call.body as { chat_id: unknown; message_id: unknown; reaction: unknown })
158
+ .filter(
159
+ (body) =>
160
+ String(body.chat_id) === String(chatId) && Number(body.message_id) === messageIdOf(update),
161
+ )
162
+ .map((body) => {
163
+ const reaction =
164
+ typeof body.reaction === "string" ? JSON.parse(body.reaction) : body.reaction;
165
+ return (reaction as Array<{ emoji?: string }>).map((r) => r.emoji ?? "").join("");
166
+ });
167
+ }
168
+
147
169
  describe("Chat SDK inbound pipeline", () => {
148
170
  test("two messages inside the buffer window become one task", async () => {
149
171
  await postTelegramUpdate(telegramUpdate(dm(2001), "first part of the request"));
@@ -233,19 +255,121 @@ describe("Chat SDK inbound pipeline", () => {
233
255
  first!.id,
234
256
  ]);
235
257
 
236
- const acksBefore = harness.mock.calls.filter((call) =>
237
- /^send(Rich)?Message$/.test(call.method),
238
- ).length;
239
- await postTelegramUpdate(telegramUpdate(group(-2005), "also add the charts"));
258
+ const sendCount = () =>
259
+ harness.mock.calls.filter((call) => /^send(Rich)?Message$/.test(call.method)).length;
260
+ const sendsBefore = sendCount();
261
+ const steer = telegramUpdate(group(-2005), "also add the charts");
262
+ await postTelegramUpdate(steer);
240
263
  await flushChatBuffers();
241
264
 
242
265
  expect(await chatTasks(-2005)).toHaveLength(1);
243
266
  expect(await steeringRows(first!.id)).toEqual([
244
267
  { body: "also add the charts", source: "chat" },
245
268
  ]);
246
- expect(
247
- harness.mock.calls.filter((call) => /^send(Rich)?Message$/.test(call.method)).length,
248
- ).toBe(acksBefore + 1);
269
+ // A reaction acknowledges the steering message; no text reply.
270
+ expect(reactionsOn(-2005, steer)).toEqual(["✍"]);
271
+ expect(sendCount()).toBe(sendsBefore);
272
+ });
273
+
274
+ test("a channel that opts into text acks also gets the steering reply", async () => {
275
+ await upsertKv({
276
+ namespace: CHAT_CHANNEL_SETTINGS_NAMESPACE,
277
+ key: "telegram:-2015",
278
+ value: { textAcks: true },
279
+ valueType: "json",
280
+ });
281
+ await postTelegramUpdate(telegramUpdate(group(-2015), "@swarm_test_bot draft the report"));
282
+ await flushChatBuffers();
283
+ const [first] = await chatTasks(-2015);
284
+ await getDbClient().run("UPDATE agent_tasks SET status = 'in_progress' WHERE id = ?", [
285
+ first!.id,
286
+ ]);
287
+
288
+ const steer = telegramUpdate(group(-2015), "also add the charts");
289
+ await postTelegramUpdate(steer);
290
+
291
+ expect(reactionsOn(-2015, steer)).toEqual(["✍"]);
292
+ const sends = harness.mock.calls.filter((call) => /^send(Rich)?Message$/.test(call.method));
293
+ // The acknowledgement replies to the steering message.
294
+ expect(sends.at(-1)?.body.reply_parameters).toEqual({
295
+ message_id: messageIdOf(steer),
296
+ allow_sending_without_reply: true,
297
+ });
298
+ });
299
+
300
+ test("an ask gets one reaction at a time; earlier buffered messages are cleared at flush", async () => {
301
+ const firstPart = telegramUpdate(dm(2016), "first part");
302
+ const secondPart = telegramUpdate(dm(2016), "second part");
303
+ await postTelegramUpdate(firstPart);
304
+ expect(reactionsOn(2016, firstPart)).toEqual(["👀"]);
305
+ await postTelegramUpdate(secondPart);
306
+ await flushChatBuffers();
307
+
308
+ expect(await chatTasks(2016)).toHaveLength(1);
309
+ // The task answers the newest message, which keeps 👀; the earlier one is cleared.
310
+ expect(reactionsOn(2016, secondPart)).toEqual(["👀"]);
311
+ expect(reactionsOn(2016, firstPart)).toEqual(["👀", ""]);
312
+
313
+ const quick = telegramUpdate(dm(2017), "!now ship it");
314
+ await postTelegramUpdate(quick);
315
+ expect(reactionsOn(2017, quick)).toEqual(["⚡"]);
316
+ });
317
+
318
+ test("a REACTION_INVALID answer is logged and the ask still becomes a task", async () => {
319
+ const warn = spyOn(console, "warn");
320
+ harness.mock.failReactions(1);
321
+ try {
322
+ const ask = telegramUpdate(dm(2018), "!now plan the sprint");
323
+ await postTelegramUpdate(ask);
324
+ expect(await chatTasks(2018)).toHaveLength(1);
325
+ expect(reactionsOn(2018, ask)).toHaveLength(1);
326
+ expect(
327
+ warn.mock.calls.some((args) => String(args[0]).includes("Could not set the now reaction")),
328
+ ).toBe(true);
329
+ } finally {
330
+ harness.mock.failReactions(0);
331
+ warn.mockRestore();
332
+ }
333
+ });
334
+
335
+ test("a mention in a subscribed group gets 👀 before the flush, and the flush does not react again", async () => {
336
+ await postTelegramUpdate(telegramUpdate(group(-2991), "@swarm_test_bot first ask"));
337
+ await flushChatBuffers();
338
+ const ask = telegramUpdate(group(-2991), "@swarm_test_bot second ask");
339
+ await postTelegramUpdate(ask);
340
+ expect(reactionsOn(-2991, ask)).toEqual(["👀"]);
341
+ await flushChatBuffers();
342
+ expect(await chatTasks(-2991)).toHaveLength(2);
343
+ expect(reactionsOn(-2991, ask)).toEqual(["👀"]);
344
+ });
345
+
346
+ test("a !now mention in a subscribed group gets ⚡, not 👀", async () => {
347
+ await postTelegramUpdate(telegramUpdate(group(-2992), "@swarm_test_bot first ask"));
348
+ await flushChatBuffers();
349
+ const ask = telegramUpdate(group(-2992), "@swarm_test_bot !now second ask");
350
+ await postTelegramUpdate(ask);
351
+ expect(await chatTasks(-2992)).toHaveLength(2);
352
+ expect(reactionsOn(-2992, ask)).toEqual(["⚡"]);
353
+ });
354
+
355
+ test("two asks back to back each anchor to their own message, not the parent's", async () => {
356
+ const firstAsk = telegramUpdate(group(-2010), "@swarm_test_bot !now summarize the PR");
357
+ await postTelegramUpdate(firstAsk);
358
+ const secondAsk = telegramUpdate(group(-2010), "@swarm_test_bot !now and list the risks");
359
+ await postTelegramUpdate(secondAsk);
360
+ await flushChatBuffers();
361
+
362
+ const rows = await chatTasks(-2010);
363
+ expect(rows).toHaveLength(2);
364
+ const [first, second] = await Promise.all(rows.map((row) => getTaskById(row.id)));
365
+ const idOf = (update: Record<string, unknown>) =>
366
+ `-2010:${(update.message as { message_id: number }).message_id}`;
367
+ expect(first?.mentionMessageId).toBe(idOf(firstAsk));
368
+ // The second task is a child of the first; `createTask` would copy the
369
+ // parent's anchor if inbound did not set its own.
370
+ expect(second?.parentTaskId).toBe(first!.id);
371
+ expect(second?.mentionMessageId).toBe(idOf(secondAsk));
372
+ expect(second?.mentionChannelId).toBeTruthy();
249
373
  });
250
374
 
251
375
  test("a Telegram private-chat DM steers too (DMs never reach onSubscribedMessage)", async () => {
@@ -282,6 +406,52 @@ describe("Chat SDK inbound pipeline", () => {
282
406
  expect(followUp?.task).toContain("include the changelog");
283
407
  });
284
408
 
409
+ test("a deferred wake-up is the thread's chat task: steering, a follow-up dependency and /cancel reach it", async () => {
410
+ await postTelegramUpdate(telegramUpdate(dm(2040), "check the deploy"));
411
+ await flushChatBuffers();
412
+ const [first] = await chatTasks(2040);
413
+ await completeTask(first!.id, "Deferred.", { deferredAt: new Date().toISOString() });
414
+ const schedule = await createScheduledTask({
415
+ name: `deferred-${first!.id.slice(0, 8)}-${crypto.randomUUID()}`,
416
+ taskTemplate: "Check the deploy and answer.",
417
+ targetType: "agent-task",
418
+ scheduleType: "one_time",
419
+ nextRunAt: new Date().toISOString(),
420
+ targetAgentId: lead.id,
421
+ createdByAgentId: lead.id,
422
+ taskType: "deferred",
423
+ parentTaskId: first!.id,
424
+ });
425
+ const wakeUp = await createStandaloneScheduleTask(schedule);
426
+ expect(wakeUp.source).toBe("schedule");
427
+ expect(wakeUp.contextKey).toBe(chatSdkContextKey("telegram:2040"));
428
+
429
+ // In progress: a plain message steers the wake-up.
430
+ await startTask(wakeUp.id);
431
+ await postTelegramUpdate(telegramUpdate(dm(2040), "use the staging cluster"));
432
+ await flushChatBuffers();
433
+ expect(await chatTasks(2040)).toHaveLength(1);
434
+ expect(await steeringRows(wakeUp.id)).toEqual([
435
+ { body: "use the staging cluster", source: "chat" },
436
+ ]);
437
+
438
+ // Pending: a new message becomes a follow-up that waits for it.
439
+ await getDbClient().run("UPDATE agent_tasks SET status = 'pending' WHERE id = ?", [wakeUp.id]);
440
+ await postTelegramUpdate(telegramUpdate(dm(2040), "and the canary"));
441
+ await flushChatBuffers();
442
+ const tasks = await chatTasks(2040);
443
+ expect(tasks).toHaveLength(2);
444
+ expect((await getTaskById(tasks[1]!.id))?.dependsOn).toEqual([wakeUp.id]);
445
+
446
+ // /cancel stops the follow-up and the wake-up.
447
+ const cancel = telegramUpdate(dm(2040), "/cancel", {
448
+ entities: [{ type: "bot_command", offset: 0, length: "/cancel".length }],
449
+ });
450
+ await postTelegramUpdate(cancel);
451
+ expect((await getTaskById(wakeUp.id))?.status).toBe("cancelled");
452
+ expect((await getTaskById(tasks[1]!.id))?.status).toBe("cancelled");
453
+ });
454
+
285
455
  test("a document attachment becomes a task attachment and a [File: ...] line", async () => {
286
456
  const bytes = new TextEncoder().encode("meeting notes\n");
287
457
  harness.mock.addFile("documents/notes.txt", bytes);
@@ -451,7 +621,11 @@ describe("Chat SDK requester and group addressing", () => {
451
621
  const deferred = await getTaskById(task!.id);
452
622
  expect(deferred?.output).toMatch(new RegExp(`at ${madrid}$`));
453
623
  expect(deferred?.output).not.toContain("UTC");
454
- expect(formatChatRelayText(deferred!)).toBe(deferred!.output!);
624
+ // The relay posts the placeholder text after the task tag (here the trigger
625
+ // drops it: a deferral posts nothing).
626
+ expect(formatChatRelayText(deferred!)).toBe(
627
+ `${formatChatTaskTag(deferred!)}\n\n${deferred!.output!}`,
628
+ );
455
629
  });
456
630
 
457
631
  test("an unknown sender gets no requester and lands in the Unmapped tracker", async () => {
@@ -25,7 +25,11 @@ import {
25
25
  import { recordTaskAttachmentUpload } from "../be/task-attachment-store";
26
26
  import { getFileStorageProvider, resetFileStorageProviderForTests } from "../fs/registry";
27
27
  import { formatUnsentFilesNote, telegramAttachmentType } from "../integrations/chat-sdk/outbound";
28
- import { processPendingChatRelays, stopChatRelayWatcher } from "../integrations/chat-sdk/relay";
28
+ import {
29
+ formatChatTaskTag,
30
+ processPendingChatRelays,
31
+ stopChatRelayWatcher,
32
+ } from "../integrations/chat-sdk/relay";
29
33
  import { chatSdkContextKey } from "../tasks/context-key";
30
34
  import { registerChatPostTool } from "../tools/chat-post";
31
35
  import { registerChatReplyTool } from "../tools/chat-reply";
@@ -646,7 +650,10 @@ describe("relay of output attachments", () => {
646
650
  expect.stringMatching(/^send(Rich)?Message$/),
647
651
  "sendDocument",
648
652
  ]);
649
- expect(textOf(calls[0]!.body)).toBe("Done, report attached.");
653
+ // The line-leading tag is escaped so Telegram does not render it as a heading.
654
+ expect(textOf(calls[0]!.body)).toBe(
655
+ `\\${formatChatTaskTag(task, lead.name)}\n\nDone, report attached.`,
656
+ );
650
657
  expect((calls[1]!.body.document as TelegramMockFilePart).filename).toBe("result.pdf");
651
658
  });
652
659
  });