@desplega.ai/agent-swarm 2.5.1 → 2.5.2

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 (155) hide show
  1. package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-csh0zz3b.js} +1 -1
  2. package/dist/{actions-w8xd014k.js → actions-6xx079d1.js} +17 -17
  3. package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-8n6f0hz8.js} +2 -2
  4. package/dist/{app-nndcvftq.js → app-ft6r6v23.js} +3 -3
  5. package/dist/{assistant-hjmqyscx.js → assistant-vt2cyfzd.js} +18 -18
  6. package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-r3e808ky.js} +1 -1
  7. package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-tgrfy27t.js} +1 -1
  8. package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-masdwxkm.js} +2 -2
  9. package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-0n82qc7q.js} +4 -4
  10. package/dist/{claude-adapter-2f8b85df.js → claude-adapter-s7j47k06.js} +2 -2
  11. package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3hzrgrnk.js} +1 -1
  12. package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-0zrdzyx2.js} +6905 -6855
  13. package/dist/{cli-mz2ygeyq.js → cli-0vgy5wfw.js} +2 -2
  14. package/dist/{cli-eym3jg7w.js → cli-14v1sxxy.js} +6 -6
  15. package/dist/{cli-e9btqw4y.js → cli-15xd0j7w.js} +18 -13
  16. package/dist/{cli-v2g93k4v.js → cli-3e9vs93g.js} +4 -4
  17. package/dist/{cli-1dp1zj04.js → cli-4qkg9968.js} +3 -3
  18. package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
  19. package/dist/{cli-rngde2jv.js → cli-6y43v79h.js} +1 -1
  20. package/dist/{cli-z63a269p.js → cli-7ccf7akh.js} +1 -1
  21. package/dist/{cli-z2dv25mf.js → cli-86zpfje5.js} +116 -5
  22. package/dist/{cli-fes51ya2.js → cli-aaqnqpyw.js} +2 -2
  23. package/dist/{cli-qfm6my86.js → cli-b1tv22wr.js} +1 -1
  24. package/dist/{cli-xc65x52q.js → cli-b63d08rs.js} +1 -1
  25. package/dist/{cli-gwcf52gz.js → cli-b6tnjdvx.js} +11 -7
  26. package/dist/{cli-9mm5ade4.js → cli-bpbcpd8b.js} +1 -1
  27. package/dist/{cli-qfjm4gvt.js → cli-cf63dnxj.js} +2 -2
  28. package/dist/{cli-q9pzwxvk.js → cli-csh8fnbg.js} +82 -16
  29. package/dist/{cli-0jy85zz4.js → cli-cyfgxnvb.js} +7 -7
  30. package/dist/{cli-y3zyaee5.js → cli-d172svcw.js} +5 -5
  31. package/dist/{cli-mq7kg1b1.js → cli-e91vshb1.js} +41 -6
  32. package/dist/{cli-cah5detr.js → cli-edtm741g.js} +5 -5
  33. package/dist/{cli-ajqhxtnz.js → cli-f3pfn4x3.js} +1 -1
  34. package/dist/{cli-hadv0725.js → cli-f55b6ky0.js} +1 -1
  35. package/dist/{cli-r9fy0ddc.js → cli-fgn7yg5r.js} +1 -1
  36. package/dist/{cli-kcsd1ez8.js → cli-h5d22pfs.js} +249 -68
  37. package/dist/{cli-2wba1ac9.js → cli-ha22h0eg.js} +20 -20
  38. package/dist/{cli-pyy4c9ch.js → cli-j3dwd4kz.js} +173 -134
  39. package/dist/{cli-j1eghqfm.js → cli-jmcjbjkt.js} +1 -1
  40. package/dist/{cli-22rmcjkg.js → cli-kcnwn85g.js} +4 -3
  41. package/dist/{cli-rh5smt8t.js → cli-mntxtkhn.js} +12 -2
  42. package/dist/{cli-2jg61wab.js → cli-n2918sed.js} +9 -9
  43. package/dist/{cli-3ee5z0q0.js → cli-qrjv7t18.js} +1 -1
  44. package/dist/{cli-sn1vbtg2.js → cli-vaxw3bg0.js} +3 -3
  45. package/dist/{cli-cwyafhw1.js → cli-wadzapwb.js} +1 -1
  46. package/dist/{cli-jpxsrxcb.js → cli-xw9q173h.js} +1 -1
  47. package/dist/{cli-znvq0k3t.js → cli-xzge46sj.js} +2 -2
  48. package/dist/{cli-nj12tn2c.js → cli-y3dzwyy3.js} +3 -3
  49. package/dist/{cli-xd4eqtr2.js → cli-ydg6ep51.js} +1 -1
  50. package/dist/cli.js +16 -15
  51. package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-brh2tcxr.js} +1 -1
  52. package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-0dnj21qs.js} +1 -1
  53. package/dist/{commands-mbvhbrcy.js → commands-1cnvseqx.js} +2 -2
  54. package/dist/{controller-gnq1k1mf.js → controller-kc7s9xhn.js} +2 -2
  55. package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-jzx7t4bn.js} +18 -18
  56. package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-qpwp5sfg.js} +1 -1
  57. package/dist/{db-af9y8nkf.js → db-4vq88jkc.js} +3 -3
  58. package/dist/{db-88wcymxt.js → db-v5ytg5pw.js} +1 -1
  59. package/dist/{dispatcher-606ydx5v.js → dispatcher-trtcrqae.js} +16 -16
  60. package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-f20xwn1z.js} +1 -1
  61. package/dist/{external-channel-y8q5z8z2.js → external-channel-n9bymc2n.js} +2 -2
  62. package/dist/{grok-adapter-39aha52t.js → grok-adapter-4kpmmnr6.js} +2 -2
  63. package/dist/{handlers-rk9dqech.js → handlers-ptsraqa8.js} +19 -19
  64. package/dist/{heartbeat-jfe9zfjr.js → heartbeat-60qtxtjf.js} +19 -19
  65. package/dist/{hook-ffevzed1.js → hook-ngtvh729.js} +1 -1
  66. package/dist/{hook-b1mez1m8.js → hook-rbktcstg.js} +1 -1
  67. package/dist/{http-d5b44f1r.js → http-z8xy72gp.js} +334 -86
  68. package/dist/{index-nq8hhv28.js → index-06qkmcx6.js} +10 -9
  69. package/dist/{index-d2qeem97.js → index-1cscynkb.js} +16 -16
  70. package/dist/{index-5en23s5z.js → index-a9nsavhx.js} +1 -1
  71. package/dist/{index-4q3vj1yr.js → index-r70q0xjx.js} +16 -16
  72. package/dist/{index-mj46rk9k.js → index-s193j5fh.js} +19 -19
  73. package/dist/{keepalive-wsby1btq.js → keepalive-a9srcphj.js} +3 -3
  74. package/dist/{lead-paa141j6.js → lead-3devneqy.js} +26 -26
  75. package/dist/{maintenance-vnshcyv9.js → maintenance-0er16h2y.js} +1 -1
  76. package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-b65jcyw2.js} +2 -2
  77. package/dist/{onboard-nr4xhpzy.js → onboard-wqywz5py.js} +2 -2
  78. package/dist/{otel-impl-ytn9wyv9.js → otel-impl-vk32s884.js} +1 -1
  79. package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-t27anv4z.js} +3 -3
  80. package/dist/rbac-roles-452665y0.js +26 -0
  81. package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-bmcgsxjn.js} +12 -2
  82. package/dist/{render-v2-pyfsg9zk.js → render-v2-0ttsk2j4.js} +3 -3
  83. package/dist/{resume-phj8xhqr.js → resume-a26kvr41.js} +5 -5
  84. package/dist/{session-access-jzx2z9h5.js → session-access-dgh9nkwk.js} +4 -4
  85. package/dist/{setup-rfg0f0sg.js → setup-9vc9qahm.js} +1 -1
  86. package/dist/{work-objects-44z2yjg8.js → work-objects-vn8nkxwn.js} +19 -19
  87. package/dist/{worker-zsfqzxhm.js → worker-1x1h8vnm.js} +26 -26
  88. package/openapi.json +463 -3
  89. package/package.json +4 -3
  90. package/src/be/db/session-tree.ts +89 -2
  91. package/src/be/db/session-visibility.ts +31 -0
  92. package/src/be/db/tasks/read.ts +18 -5
  93. package/src/be/db.ts +2 -0
  94. package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
  95. package/src/be/migrations/235_chat_relay_defer.sql +86 -0
  96. package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
  97. package/src/be/rbac-roles.ts +138 -0
  98. package/src/be/scripts/run-credential.ts +64 -0
  99. package/src/be/scripts/run-saved.ts +3 -2
  100. package/src/be/users.ts +28 -4
  101. package/src/http/all-routes.ts +1 -0
  102. package/src/http/approval-requests.ts +46 -6
  103. package/src/http/index.ts +2 -0
  104. package/src/http/schedules.ts +26 -0
  105. package/src/http/scripts.ts +2 -2
  106. package/src/http/sessions.ts +93 -2
  107. package/src/http/user-roles.ts +173 -0
  108. package/src/http/users.ts +9 -0
  109. package/src/http/x.ts +2 -2
  110. package/src/integrations/chat-sdk/commands.ts +14 -1
  111. package/src/integrations/chat-sdk/inbound.ts +40 -5
  112. package/src/integrations/chat-sdk/outbound.ts +35 -10
  113. package/src/integrations/chat-sdk/reactions.ts +107 -0
  114. package/src/integrations/chat-sdk/relay.ts +75 -7
  115. package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
  116. package/src/integrations/reaction-events.ts +20 -0
  117. package/src/providers/amp-adapter.ts +1 -1
  118. package/src/rbac/legacy-policy.ts +3 -0
  119. package/src/rbac/permissions.ts +8 -0
  120. package/src/scheduler/schedule-task.ts +44 -3
  121. package/src/scheduler/scheduler.ts +4 -4
  122. package/src/scripts-runtime/loader.ts +8 -1
  123. package/src/slack/ack.ts +2 -1
  124. package/src/slack/reaction-shortcode.ts +4 -7
  125. package/src/tasks/worker-follow-up.ts +30 -0
  126. package/src/tests/approval-requests-notifications.test.ts +189 -0
  127. package/src/tests/chat-sdk-commands.test.ts +12 -0
  128. package/src/tests/chat-sdk-inbound.test.ts +163 -9
  129. package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
  130. package/src/tests/chat-sdk-relay.test.ts +361 -10
  131. package/src/tests/chat-sdk-tools.test.ts +104 -0
  132. package/src/tests/docs-index-footprint.test.js +3 -3
  133. package/src/tests/http-users.test.ts +38 -0
  134. package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
  135. package/src/tests/rbac-engine.test.ts +2 -0
  136. package/src/tests/rbac-roles.test.ts +10 -0
  137. package/src/tests/schedule-http-triage-tools.test.ts +44 -0
  138. package/src/tests/script-api-credential.test.ts +173 -0
  139. package/src/tests/script-connections.test.ts +4 -0
  140. package/src/tests/script-run-config-scope.test.ts +120 -0
  141. package/src/tests/script-run-token.test.ts +75 -0
  142. package/src/tests/scripts-http.test.ts +19 -0
  143. package/src/tests/sessions-tree.test.ts +242 -0
  144. package/src/tests/user-roles-http.test.ts +204 -0
  145. package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
  146. package/src/tools/chat-reply.ts +2 -0
  147. package/src/tools/schedules/patch-schedule.ts +18 -0
  148. package/src/tools/schedules/update-schedule.ts +18 -0
  149. package/src/types.ts +16 -0
  150. package/src/workflows/approval-notifications.ts +214 -0
  151. package/src/workflows/executors/human-in-the-loop.ts +11 -172
  152. package/src/workflows/executors/swarm-script.ts +2 -2
  153. package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
  154. package/templates/skills/code-quality/content.md +1 -1
  155. package/dist/rbac-roles-ff1ffmec.js +0 -12
@@ -25,7 +25,7 @@ import {
25
25
  } from "@/be/script-connections";
26
26
  import { buildScriptCredentialBindingsWithFailures } from "@/be/script-credential-broker";
27
27
  import { getScript } from "@/be/scripts/db";
28
- import { runScript } from "@/scripts-runtime/loader";
28
+ import { runScriptWithRunCredential } from "@/be/scripts/run-credential";
29
29
  import { telemetry } from "@/telemetry";
30
30
  import type { AgentTask, ScheduledTask } from "@/types";
31
31
  import { getExecutorRegistry as getWorkflowExecutorRegistry } from "@/workflows";
@@ -72,7 +72,7 @@ function resolveExecutorRegistry(): ExecutorRegistry | null {
72
72
 
73
73
  /**
74
74
  * Execute a schedule's `targetType='script'` target directly via the
75
- * scripts-runtime — no agent/LLM in the loop. Reuses the same `runScript()`
75
+ * scripts-runtime — no agent/LLM in the loop. Reuses the same `runScriptWithRunCredential()`
76
76
  * path as the `swarm-script` workflow executor.
77
77
  */
78
78
  async function executeScheduleScript(schedule: ScheduledTask): Promise<void> {
@@ -98,10 +98,10 @@ async function executeScheduleScript(schedule: ScheduledTask): Promise<void> {
98
98
  startedAt: new Date().toISOString(),
99
99
  };
100
100
 
101
- let output: Awaited<ReturnType<typeof runScript>>;
101
+ let output: Awaited<ReturnType<typeof runScriptWithRunCredential>>;
102
102
  try {
103
103
  const credentials = await buildScriptCredentialBindingsWithFailures({ agentId });
104
- output = await runScript({
104
+ output = await runScriptWithRunCredential({
105
105
  source: script.source,
106
106
  args,
107
107
  fsMode: "none",
@@ -40,6 +40,13 @@ export type RunScriptInput = {
40
40
  args?: unknown;
41
41
  fsMode?: ScriptFsMode;
42
42
  agentId: string;
43
+ /**
44
+ * Bearer for the run's `ctx.swarm` SDK calls. Api-side callers pass a
45
+ * per-run token bound to the executing agent (`runScriptWithRunCredential`
46
+ * in `src/be/scripts/run-credential.ts`, which also revokes it). Omitted, the
47
+ * run presents the swarm API key.
48
+ */
49
+ apiKey?: string;
43
50
  /** Per-boot runtime identity of the invoking worker; system context, never script input. */
44
51
  runtimeInstanceId?: string;
45
52
  signal?: AbortSignal;
@@ -74,7 +81,7 @@ export function scriptEgressPolicy(env: NodeJS.ProcessEnv = process.env): Egress
74
81
  }
75
82
 
76
83
  async function buildConfigPayload(input: RunScriptInput): Promise<SwarmConfigPayload> {
77
- const apiKey = getApiKey();
84
+ const apiKey = input.apiKey ?? getApiKey();
78
85
  if (!apiKey) throw new Error("Swarm API key is required to run scripts");
79
86
 
80
87
  return {
package/src/slack/ack.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { WebClient } from "@slack/web-api";
2
2
  import { getLogsByTaskIdChronological, getSlackTasksInThread, getTaskById } from "../be/db";
3
+ import { terminalReactionEvent } from "../integrations/reaction-events";
3
4
  import { recordSlackReactionInvalidName } from "../otel";
4
5
  import { type AgentTask, isTerminalTaskStatus } from "../types";
5
6
  import { scrubSecrets } from "../utils/secret-scrubber";
@@ -143,7 +144,7 @@ export type SlackReactionChoice = { name: string; event?: SlackReactionEvent };
143
144
 
144
145
  /** The reaction choice for a task's terminal outcome, honouring operator config. */
145
146
  export function terminalReactionChoice(task: AgentTask): SlackReactionChoice {
146
- const event: SlackReactionEvent = task.status === "completed" ? "completed" : "failed";
147
+ const event = terminalReactionEvent(task.status);
147
148
  return { name: reactionName(event), event };
148
149
  }
149
150
 
@@ -1,10 +1,7 @@
1
- export type SlackReactionEvent =
2
- | "accepted"
3
- | "buffered"
4
- | "now"
5
- | "steered"
6
- | "completed"
7
- | "failed";
1
+ import type { ReactionEvent } from "@/integrations/reaction-events";
2
+
3
+ /** The shared lifecycle events; Slack maps each one to a shortcode below. */
4
+ export type SlackReactionEvent = ReactionEvent;
8
5
 
9
6
  export const SLACK_REACTION_DEFAULTS: Record<SlackReactionEvent, string> = {
10
7
  accepted: "eyes",
@@ -33,6 +33,7 @@ import {
33
33
  } from "../types";
34
34
  import { isEnvFlagEnabled } from "../utils/env-flag";
35
35
  import { taskAttachmentDisplayUrl } from "../utils/task-attachment-links";
36
+ import { chatThreadOfContextKey } from "./context-key";
36
37
  import { taskModelPin } from "./model-pin";
37
38
  import { createTaskWithSiblingAwareness } from "./sibling-awareness";
38
39
  // Side-effect import: registers task lifecycle templates in the in-memory registry.
@@ -386,6 +387,7 @@ export async function createWorkerTaskFollowUp(args: {
386
387
  taskType: "follow-up",
387
388
  priority: changes.priority,
388
389
  parentTaskId: task.id,
390
+ contextKey: await deferredWorkChatContextKey(task),
389
391
  slackChannelId: task.slackChannelId,
390
392
  slackThreadTs: task.slackThreadTs,
391
393
  slackUserId: task.slackUserId,
@@ -398,6 +400,34 @@ export async function createWorkerTaskFollowUp(args: {
398
400
  );
399
401
  }
400
402
 
403
+ /** Hop cap for `deferredWorkChatContextKey`; a defer/resume chain is short. */
404
+ const MAX_CONTINUATION_HOPS = 32;
405
+
406
+ /**
407
+ * The chat thread a worker's deferred work answers in, for its Lead follow-up.
408
+ * A worker's defer-task wake-up (and any resume of it) runs on its schedule's
409
+ * contextKey, so the worker stays out of the chat (`scheduleTaskContextKey`).
410
+ * The Lead follow-up still answers the original ask: walk back through the
411
+ * wake-up and resume chain to the deferred task and report on its chat thread.
412
+ * Undefined when the task already has a chat key or the chain leads to none,
413
+ * so the follow-up inherits the task's own key as before.
414
+ */
415
+ async function deferredWorkChatContextKey(task: AgentTask): Promise<string | undefined> {
416
+ if (chatThreadOfContextKey(task.contextKey)) return undefined;
417
+ const seen = new Set<string>();
418
+ let current: AgentTask | null = task;
419
+ while (current && !seen.has(current.id) && seen.size < MAX_CONTINUATION_HOPS) {
420
+ if (chatThreadOfContextKey(current.contextKey)) return current.contextKey;
421
+ const continuation =
422
+ (current.source === "schedule" && current.taskType === "deferred") ||
423
+ (current.source === "system" && current.taskType === "resume");
424
+ if (!continuation || !current.parentTaskId) return undefined;
425
+ seen.add(current.id);
426
+ current = await getTaskById(current.parentTaskId);
427
+ }
428
+ return undefined;
429
+ }
430
+
401
431
  /** Result of `createResumeFollowUp`. */
402
432
  export type CreateResumeFollowUpResult =
403
433
  | { kind: "created"; task: AgentTask }
@@ -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,101 @@ 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("two asks back to back each anchor to their own message, not the parent's", async () => {
336
+ const firstAsk = telegramUpdate(group(-2010), "@swarm_test_bot !now summarize the PR");
337
+ await postTelegramUpdate(firstAsk);
338
+ const secondAsk = telegramUpdate(group(-2010), "@swarm_test_bot !now and list the risks");
339
+ await postTelegramUpdate(secondAsk);
340
+ await flushChatBuffers();
341
+
342
+ const rows = await chatTasks(-2010);
343
+ expect(rows).toHaveLength(2);
344
+ const [first, second] = await Promise.all(rows.map((row) => getTaskById(row.id)));
345
+ const idOf = (update: Record<string, unknown>) =>
346
+ `-2010:${(update.message as { message_id: number }).message_id}`;
347
+ expect(first?.mentionMessageId).toBe(idOf(firstAsk));
348
+ // The second task is a child of the first; `createTask` would copy the
349
+ // parent's anchor if inbound did not set its own.
350
+ expect(second?.parentTaskId).toBe(first!.id);
351
+ expect(second?.mentionMessageId).toBe(idOf(secondAsk));
352
+ expect(second?.mentionChannelId).toBeTruthy();
249
353
  });
250
354
 
251
355
  test("a Telegram private-chat DM steers too (DMs never reach onSubscribedMessage)", async () => {
@@ -282,6 +386,52 @@ describe("Chat SDK inbound pipeline", () => {
282
386
  expect(followUp?.task).toContain("include the changelog");
283
387
  });
284
388
 
389
+ test("a deferred wake-up is the thread's chat task: steering, a follow-up dependency and /cancel reach it", async () => {
390
+ await postTelegramUpdate(telegramUpdate(dm(2040), "check the deploy"));
391
+ await flushChatBuffers();
392
+ const [first] = await chatTasks(2040);
393
+ await completeTask(first!.id, "Deferred.", { deferredAt: new Date().toISOString() });
394
+ const schedule = await createScheduledTask({
395
+ name: `deferred-${first!.id.slice(0, 8)}-${crypto.randomUUID()}`,
396
+ taskTemplate: "Check the deploy and answer.",
397
+ targetType: "agent-task",
398
+ scheduleType: "one_time",
399
+ nextRunAt: new Date().toISOString(),
400
+ targetAgentId: lead.id,
401
+ createdByAgentId: lead.id,
402
+ taskType: "deferred",
403
+ parentTaskId: first!.id,
404
+ });
405
+ const wakeUp = await createStandaloneScheduleTask(schedule);
406
+ expect(wakeUp.source).toBe("schedule");
407
+ expect(wakeUp.contextKey).toBe(chatSdkContextKey("telegram:2040"));
408
+
409
+ // In progress: a plain message steers the wake-up.
410
+ await startTask(wakeUp.id);
411
+ await postTelegramUpdate(telegramUpdate(dm(2040), "use the staging cluster"));
412
+ await flushChatBuffers();
413
+ expect(await chatTasks(2040)).toHaveLength(1);
414
+ expect(await steeringRows(wakeUp.id)).toEqual([
415
+ { body: "use the staging cluster", source: "chat" },
416
+ ]);
417
+
418
+ // Pending: a new message becomes a follow-up that waits for it.
419
+ await getDbClient().run("UPDATE agent_tasks SET status = 'pending' WHERE id = ?", [wakeUp.id]);
420
+ await postTelegramUpdate(telegramUpdate(dm(2040), "and the canary"));
421
+ await flushChatBuffers();
422
+ const tasks = await chatTasks(2040);
423
+ expect(tasks).toHaveLength(2);
424
+ expect((await getTaskById(tasks[1]!.id))?.dependsOn).toEqual([wakeUp.id]);
425
+
426
+ // /cancel stops the follow-up and the wake-up.
427
+ const cancel = telegramUpdate(dm(2040), "/cancel", {
428
+ entities: [{ type: "bot_command", offset: 0, length: "/cancel".length }],
429
+ });
430
+ await postTelegramUpdate(cancel);
431
+ expect((await getTaskById(wakeUp.id))?.status).toBe("cancelled");
432
+ expect((await getTaskById(tasks[1]!.id))?.status).toBe("cancelled");
433
+ });
434
+
285
435
  test("a document attachment becomes a task attachment and a [File: ...] line", async () => {
286
436
  const bytes = new TextEncoder().encode("meeting notes\n");
287
437
  harness.mock.addFile("documents/notes.txt", bytes);
@@ -451,7 +601,11 @@ describe("Chat SDK requester and group addressing", () => {
451
601
  const deferred = await getTaskById(task!.id);
452
602
  expect(deferred?.output).toMatch(new RegExp(`at ${madrid}$`));
453
603
  expect(deferred?.output).not.toContain("UTC");
454
- expect(formatChatRelayText(deferred!)).toBe(deferred!.output!);
604
+ // The relay posts the placeholder text after the task tag (here the trigger
605
+ // drops it: a deferral posts nothing).
606
+ expect(formatChatRelayText(deferred!)).toBe(
607
+ `${formatChatTaskTag(deferred!)}\n\n${deferred!.output!}`,
608
+ );
455
609
  });
456
610
 
457
611
  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
  });