@desplega.ai/agent-swarm 1.51.2 → 1.52.1

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 (55) hide show
  1. package/README.md +131 -0
  2. package/openapi.json +767 -4
  3. package/package.json +3 -1
  4. package/src/be/db.ts +669 -0
  5. package/src/be/migrations/019_skills.sql +65 -0
  6. package/src/be/migrations/020_approval_requests.sql +41 -0
  7. package/src/be/migrations/runner.ts +4 -4
  8. package/src/be/skill-parser.ts +70 -0
  9. package/src/be/skill-sync.ts +106 -0
  10. package/src/commands/runner.ts +299 -52
  11. package/src/http/agents.ts +29 -0
  12. package/src/http/approval-requests.ts +310 -0
  13. package/src/http/config.ts +3 -3
  14. package/src/http/index.ts +26 -2
  15. package/src/http/poll.ts +15 -0
  16. package/src/http/skills.ts +479 -0
  17. package/src/http/tasks.ts +94 -0
  18. package/src/linear/outbound.ts +12 -12
  19. package/src/prompts/base-prompt.ts +8 -0
  20. package/src/providers/claude-adapter.ts +19 -3
  21. package/src/scheduler/scheduler.ts +24 -1
  22. package/src/server.ts +29 -0
  23. package/src/slack/blocks.ts +1 -1
  24. package/src/tests/approval-requests.test.ts +948 -0
  25. package/src/tests/skill-parser.test.ts +178 -0
  26. package/src/tests/skill-sync.test.ts +171 -0
  27. package/src/tests/slack-blocks.test.ts +3 -2
  28. package/src/tests/structured-output.test.ts +1 -0
  29. package/src/tests/tool-annotations.test.ts +2 -1
  30. package/src/tests/tool-call-progress.test.ts +207 -0
  31. package/src/tests/tool-registrar-no-input.test.ts +114 -0
  32. package/src/tests/update-profile-auth.test.ts +1 -0
  33. package/src/tests/workflow-executors.test.ts +4 -2
  34. package/src/tools/request-human-input.ts +117 -0
  35. package/src/tools/skills/index.ts +11 -0
  36. package/src/tools/skills/skill-create.ts +105 -0
  37. package/src/tools/skills/skill-delete.ts +67 -0
  38. package/src/tools/skills/skill-get.ts +75 -0
  39. package/src/tools/skills/skill-install-remote.ts +152 -0
  40. package/src/tools/skills/skill-install.ts +101 -0
  41. package/src/tools/skills/skill-list.ts +77 -0
  42. package/src/tools/skills/skill-publish.ts +123 -0
  43. package/src/tools/skills/skill-search.ts +43 -0
  44. package/src/tools/skills/skill-sync-remote.ts +128 -0
  45. package/src/tools/skills/skill-uninstall.ts +60 -0
  46. package/src/tools/skills/skill-update.ts +128 -0
  47. package/src/tools/store-progress.ts +31 -0
  48. package/src/tools/templates.ts +28 -0
  49. package/src/tools/tool-config.ts +16 -0
  50. package/src/tools/utils.ts +9 -7
  51. package/src/types.ts +54 -0
  52. package/src/workflows/executors/human-in-the-loop.ts +273 -0
  53. package/src/workflows/executors/registry.ts +2 -0
  54. package/src/workflows/recovery.ts +72 -0
  55. package/src/workflows/resume.ts +65 -1
@@ -0,0 +1,948 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { createServer as createHttpServer, type Server } from "node:http";
4
+ import {
5
+ closeDb,
6
+ createAgent,
7
+ createApprovalRequest,
8
+ createTaskExtended,
9
+ getAgentCurrentTask,
10
+ getApprovalRequestById,
11
+ getApprovalRequestByStepId,
12
+ getExpiredPendingApprovals,
13
+ initDb,
14
+ listApprovalRequests,
15
+ resolveApprovalRequest,
16
+ startTask,
17
+ updateApprovalRequestNotifications,
18
+ } from "../be/db";
19
+ import type { ExecutorMeta } from "../types";
20
+ import type { ExecutorDependencies, ExecutorInput } from "../workflows/executors/base";
21
+ import { HumanInTheLoopExecutor } from "../workflows/executors/human-in-the-loop";
22
+
23
+ const TEST_DB_PATH = "./test-approval-requests.sqlite";
24
+ const TEST_PORT = 13031;
25
+
26
+ // ─── Helpers ────────────────────────────────────────────────
27
+
28
+ function getPathSegments(url: string): string[] {
29
+ const pathEnd = url.indexOf("?");
30
+ const path = pathEnd === -1 ? url : url.slice(0, pathEnd);
31
+ return path.split("/").filter(Boolean);
32
+ }
33
+
34
+ function parseQueryParams(url: string): URLSearchParams {
35
+ const queryIndex = url.indexOf("?");
36
+ if (queryIndex === -1) return new URLSearchParams();
37
+ return new URLSearchParams(url.slice(queryIndex + 1));
38
+ }
39
+
40
+ function makeApprovalData(overrides?: Record<string, unknown>) {
41
+ return {
42
+ id: crypto.randomUUID(),
43
+ title: "Approve deployment",
44
+ questions: [{ id: "q1", type: "approval", label: "Approve?", required: true }],
45
+ approvers: { policy: "any" as const },
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ // ─── HTTP handler (mirrors production routes) ───────────────
51
+
52
+ async function handleRequest(
53
+ req: { method: string; url: string },
54
+ body: string,
55
+ ): Promise<{ status: number; body: unknown }> {
56
+ const pathSegments = getPathSegments(req.url || "");
57
+ const queryParams = parseQueryParams(req.url || "");
58
+
59
+ // POST /api/approval-requests
60
+ if (
61
+ req.method === "POST" &&
62
+ pathSegments[0] === "api" &&
63
+ pathSegments[1] === "approval-requests" &&
64
+ !pathSegments[2]
65
+ ) {
66
+ const data = JSON.parse(body);
67
+ const id = crypto.randomUUID();
68
+ const request = createApprovalRequest({
69
+ id,
70
+ title: data.title,
71
+ questions: data.questions,
72
+ approvers: data.approvers,
73
+ workflowRunId: data.workflowRunId,
74
+ workflowRunStepId: data.workflowRunStepId,
75
+ sourceTaskId: data.sourceTaskId,
76
+ timeoutSeconds: data.timeoutSeconds,
77
+ notificationChannels: data.notifications,
78
+ });
79
+ return { status: 201, body: { approvalRequest: request } };
80
+ }
81
+
82
+ // GET /api/approval-requests (list)
83
+ if (
84
+ req.method === "GET" &&
85
+ pathSegments[0] === "api" &&
86
+ pathSegments[1] === "approval-requests" &&
87
+ !pathSegments[2]
88
+ ) {
89
+ const requests = listApprovalRequests({
90
+ status: queryParams.get("status") || undefined,
91
+ workflowRunId: queryParams.get("workflowRunId") || undefined,
92
+ limit: queryParams.get("limit") ? Number(queryParams.get("limit")) : undefined,
93
+ });
94
+ return { status: 200, body: { approvalRequests: requests } };
95
+ }
96
+
97
+ // POST /api/approval-requests/:id/respond
98
+ if (
99
+ req.method === "POST" &&
100
+ pathSegments[0] === "api" &&
101
+ pathSegments[1] === "approval-requests" &&
102
+ pathSegments[2] &&
103
+ pathSegments[3] === "respond"
104
+ ) {
105
+ const id = pathSegments[2];
106
+ const existing = getApprovalRequestById(id);
107
+ if (!existing) return { status: 404, body: { error: "Not found" } };
108
+ if (existing.status !== "pending") {
109
+ return { status: 409, body: { error: `Already resolved: ${existing.status}` } };
110
+ }
111
+
112
+ const data = JSON.parse(body);
113
+ const questions = existing.questions as Array<{ id: string; type: string }>;
114
+ let status: "approved" | "rejected" = "approved";
115
+ for (const q of questions) {
116
+ if (q.type === "approval") {
117
+ const answer = data.responses[q.id] as { approved?: boolean } | undefined;
118
+ if (answer && answer.approved === false) {
119
+ status = "rejected";
120
+ break;
121
+ }
122
+ }
123
+ }
124
+
125
+ const updated = resolveApprovalRequest(id, {
126
+ status,
127
+ responses: data.responses,
128
+ resolvedBy: data.respondedBy,
129
+ });
130
+
131
+ if (!updated) return { status: 409, body: { error: "Concurrent resolution" } };
132
+ return { status: 200, body: { approvalRequest: updated } };
133
+ }
134
+
135
+ // GET /api/approval-requests/:id
136
+ if (
137
+ req.method === "GET" &&
138
+ pathSegments[0] === "api" &&
139
+ pathSegments[1] === "approval-requests" &&
140
+ pathSegments[2]
141
+ ) {
142
+ const request = getApprovalRequestById(pathSegments[2]);
143
+ if (!request) return { status: 404, body: { error: "Not found" } };
144
+ return { status: 200, body: { approvalRequest: request } };
145
+ }
146
+
147
+ return { status: 404, body: { error: "Not found" } };
148
+ }
149
+
150
+ function createTestServer(): Server {
151
+ return createHttpServer(async (req, res) => {
152
+ res.setHeader("Content-Type", "application/json");
153
+ const chunks: Buffer[] = [];
154
+ for await (const chunk of req) {
155
+ chunks.push(chunk);
156
+ }
157
+ const body = Buffer.concat(chunks).toString();
158
+ const result = await handleRequest({ method: req.method || "GET", url: req.url || "/" }, body);
159
+ res.writeHead(result.status);
160
+ res.end(JSON.stringify(result.body));
161
+ });
162
+ }
163
+
164
+ // ─── Test Setup ─────────────────────────────────────────────
165
+
166
+ describe("Approval Requests", () => {
167
+ let server: Server;
168
+ const baseUrl = `http://localhost:${TEST_PORT}`;
169
+
170
+ beforeAll(async () => {
171
+ try {
172
+ await unlink(TEST_DB_PATH);
173
+ } catch {}
174
+ initDb(TEST_DB_PATH);
175
+ server = createTestServer();
176
+ await new Promise<void>((resolve) => {
177
+ server.listen(TEST_PORT, () => resolve());
178
+ });
179
+ });
180
+
181
+ afterAll(async () => {
182
+ await new Promise<void>((resolve) => {
183
+ server.close(() => resolve());
184
+ });
185
+ closeDb();
186
+ try {
187
+ await unlink(TEST_DB_PATH);
188
+ await unlink(`${TEST_DB_PATH}-wal`);
189
+ await unlink(`${TEST_DB_PATH}-shm`);
190
+ } catch {}
191
+ });
192
+
193
+ // ─── DB Functions ───────────────────────────────────────────
194
+
195
+ describe("DB: createApprovalRequest", () => {
196
+ test("creates a minimal approval request", () => {
197
+ const data = makeApprovalData();
198
+ const result = createApprovalRequest(data);
199
+
200
+ expect(result.id).toBe(data.id);
201
+ expect(result.title).toBe("Approve deployment");
202
+ expect(result.status).toBe("pending");
203
+ expect(result.questions).toEqual(data.questions);
204
+ expect(result.responses).toBeNull();
205
+ expect(result.resolvedBy).toBeNull();
206
+ expect(result.resolvedAt).toBeNull();
207
+ expect(result.expiresAt).toBeNull();
208
+ expect(result.createdAt).toBeTruthy();
209
+ });
210
+
211
+ test("creates request with timeout and computes expiresAt", () => {
212
+ const data = makeApprovalData({ timeoutSeconds: 3600 });
213
+ const before = Date.now();
214
+ const result = createApprovalRequest(data);
215
+
216
+ expect(result.expiresAt).toBeTruthy();
217
+ const expiresMs = new Date(result.expiresAt!).getTime();
218
+ // expiresAt should be roughly now + 3600s
219
+ expect(expiresMs).toBeGreaterThanOrEqual(before + 3600 * 1000 - 5000);
220
+ expect(expiresMs).toBeLessThanOrEqual(before + 3600 * 1000 + 5000);
221
+ });
222
+
223
+ test("creates request with workflow linkage", () => {
224
+ const runId = crypto.randomUUID();
225
+ const stepId = crypto.randomUUID();
226
+ const data = makeApprovalData({ workflowRunId: runId, workflowRunStepId: stepId });
227
+ const result = createApprovalRequest(data);
228
+
229
+ expect(result.workflowRunId).toBe(runId);
230
+ expect(result.workflowRunStepId).toBe(stepId);
231
+ });
232
+
233
+ test("creates request with notification channels", () => {
234
+ const data = makeApprovalData({
235
+ notificationChannels: [{ channel: "slack", target: "#general" }],
236
+ });
237
+ const result = createApprovalRequest(data);
238
+ expect(result.notificationChannels).toEqual([{ channel: "slack", target: "#general" }]);
239
+ });
240
+
241
+ test("creates request with multiple question types", () => {
242
+ const questions = [
243
+ { id: "q1", type: "approval", label: "Approve?", required: true },
244
+ { id: "q2", type: "text", label: "Comments", required: false },
245
+ {
246
+ id: "q3",
247
+ type: "single-select",
248
+ label: "Priority",
249
+ options: [
250
+ { value: "high", label: "High" },
251
+ { value: "low", label: "Low" },
252
+ ],
253
+ },
254
+ { id: "q4", type: "boolean", label: "Urgent?", defaultValue: false },
255
+ ];
256
+ const data = makeApprovalData({ questions });
257
+ const result = createApprovalRequest(data);
258
+ expect(result.questions).toEqual(questions);
259
+ });
260
+ });
261
+
262
+ describe("DB: getApprovalRequestById", () => {
263
+ test("returns null for nonexistent ID", () => {
264
+ expect(getApprovalRequestById(crypto.randomUUID())).toBeNull();
265
+ });
266
+
267
+ test("returns the correct request", () => {
268
+ const data = makeApprovalData();
269
+ createApprovalRequest(data);
270
+ const fetched = getApprovalRequestById(data.id);
271
+ expect(fetched).not.toBeNull();
272
+ expect(fetched!.id).toBe(data.id);
273
+ expect(fetched!.title).toBe(data.title);
274
+ });
275
+ });
276
+
277
+ describe("DB: getApprovalRequestByStepId", () => {
278
+ test("returns null when no request for step", () => {
279
+ expect(getApprovalRequestByStepId(crypto.randomUUID())).toBeNull();
280
+ });
281
+
282
+ test("returns the request linked to a step", () => {
283
+ const stepId = crypto.randomUUID();
284
+ const data = makeApprovalData({
285
+ workflowRunId: crypto.randomUUID(),
286
+ workflowRunStepId: stepId,
287
+ });
288
+ createApprovalRequest(data);
289
+ const fetched = getApprovalRequestByStepId(stepId);
290
+ expect(fetched).not.toBeNull();
291
+ expect(fetched!.id).toBe(data.id);
292
+ });
293
+ });
294
+
295
+ describe("DB: resolveApprovalRequest", () => {
296
+ test("resolves a pending request to approved", () => {
297
+ const data = makeApprovalData();
298
+ createApprovalRequest(data);
299
+
300
+ const result = resolveApprovalRequest(data.id, {
301
+ status: "approved",
302
+ responses: { q1: { approved: true } },
303
+ resolvedBy: "user-1",
304
+ });
305
+
306
+ expect(result).not.toBeNull();
307
+ expect(result!.status).toBe("approved");
308
+ expect(result!.responses).toEqual({ q1: { approved: true } });
309
+ expect(result!.resolvedBy).toBe("user-1");
310
+ expect(result!.resolvedAt).toBeTruthy();
311
+ });
312
+
313
+ test("resolves a pending request to rejected", () => {
314
+ const data = makeApprovalData();
315
+ createApprovalRequest(data);
316
+
317
+ const result = resolveApprovalRequest(data.id, { status: "rejected" });
318
+ expect(result).not.toBeNull();
319
+ expect(result!.status).toBe("rejected");
320
+ });
321
+
322
+ test("returns null when trying to resolve an already-resolved request", () => {
323
+ const data = makeApprovalData();
324
+ createApprovalRequest(data);
325
+ resolveApprovalRequest(data.id, { status: "approved" });
326
+
327
+ // Second resolve should fail (idempotency guard)
328
+ const result = resolveApprovalRequest(data.id, { status: "rejected" });
329
+ expect(result).toBeNull();
330
+ });
331
+
332
+ test("returns null for nonexistent ID", () => {
333
+ const result = resolveApprovalRequest(crypto.randomUUID(), { status: "approved" });
334
+ expect(result).toBeNull();
335
+ });
336
+ });
337
+
338
+ describe("DB: listApprovalRequests", () => {
339
+ test("lists all requests (with limit)", () => {
340
+ const results = listApprovalRequests({ limit: 1000 });
341
+ expect(results.length).toBeGreaterThan(0);
342
+ });
343
+
344
+ test("filters by status", () => {
345
+ // Create a fresh pending one
346
+ const data = makeApprovalData();
347
+ createApprovalRequest(data);
348
+
349
+ const pending = listApprovalRequests({ status: "pending" });
350
+ expect(pending.length).toBeGreaterThan(0);
351
+ for (const r of pending) {
352
+ expect(r.status).toBe("pending");
353
+ }
354
+ });
355
+
356
+ test("filters by workflowRunId", () => {
357
+ const runId = crypto.randomUUID();
358
+ const data = makeApprovalData({ workflowRunId: runId });
359
+ createApprovalRequest(data);
360
+
361
+ const results = listApprovalRequests({ workflowRunId: runId });
362
+ expect(results).toHaveLength(1);
363
+ expect(results[0].workflowRunId).toBe(runId);
364
+ });
365
+
366
+ test("respects limit", () => {
367
+ const results = listApprovalRequests({ limit: 1 });
368
+ expect(results).toHaveLength(1);
369
+ });
370
+ });
371
+
372
+ describe("DB: getExpiredPendingApprovals", () => {
373
+ test("returns empty for non-expired requests", () => {
374
+ // All our test requests with timeout have expiresAt in the future
375
+ const expired = getExpiredPendingApprovals();
376
+ // Filter to only our test requests
377
+ for (const r of expired) {
378
+ expect(r.status).toBe("pending");
379
+ expect(r.expiresAt).toBeTruthy();
380
+ }
381
+ });
382
+ });
383
+
384
+ // ─── HTTP Endpoints ─────────────────────────────────────────
385
+
386
+ describe("HTTP: POST /api/approval-requests", () => {
387
+ test("creates an approval request and returns 201", async () => {
388
+ const res = await fetch(`${baseUrl}/api/approval-requests`, {
389
+ method: "POST",
390
+ headers: { "Content-Type": "application/json" },
391
+ body: JSON.stringify({
392
+ title: "Deploy to production?",
393
+ questions: [{ id: "q1", type: "approval", label: "Approve?", required: true }],
394
+ approvers: { policy: "any" },
395
+ }),
396
+ });
397
+
398
+ expect(res.status).toBe(201);
399
+ const data = (await res.json()) as {
400
+ approvalRequest: { id: string; status: string; title: string };
401
+ };
402
+ expect(data.approvalRequest.id).toBeTruthy();
403
+ expect(data.approvalRequest.status).toBe("pending");
404
+ expect(data.approvalRequest.title).toBe("Deploy to production?");
405
+ });
406
+ });
407
+
408
+ describe("HTTP: GET /api/approval-requests", () => {
409
+ test("lists approval requests", async () => {
410
+ const res = await fetch(`${baseUrl}/api/approval-requests`);
411
+ expect(res.status).toBe(200);
412
+ const data = (await res.json()) as { approvalRequests: unknown[] };
413
+ expect(data.approvalRequests.length).toBeGreaterThan(0);
414
+ });
415
+
416
+ test("filters by status", async () => {
417
+ const res = await fetch(`${baseUrl}/api/approval-requests?status=pending`);
418
+ expect(res.status).toBe(200);
419
+ const data = (await res.json()) as { approvalRequests: Array<{ status: string }> };
420
+ for (const r of data.approvalRequests) {
421
+ expect(r.status).toBe("pending");
422
+ }
423
+ });
424
+
425
+ test("filters by workflowRunId", async () => {
426
+ const runId = crypto.randomUUID();
427
+ // Create one with this runId
428
+ createApprovalRequest(makeApprovalData({ workflowRunId: runId }));
429
+
430
+ const res = await fetch(`${baseUrl}/api/approval-requests?workflowRunId=${runId}`);
431
+ expect(res.status).toBe(200);
432
+ const data = (await res.json()) as { approvalRequests: Array<{ workflowRunId: string }> };
433
+ expect(data.approvalRequests).toHaveLength(1);
434
+ expect(data.approvalRequests[0].workflowRunId).toBe(runId);
435
+ });
436
+ });
437
+
438
+ describe("HTTP: GET /api/approval-requests/:id", () => {
439
+ test("returns 404 for nonexistent ID", async () => {
440
+ const res = await fetch(`${baseUrl}/api/approval-requests/${crypto.randomUUID()}`);
441
+ expect(res.status).toBe(404);
442
+ });
443
+
444
+ test("returns the request", async () => {
445
+ const created = createApprovalRequest(makeApprovalData());
446
+ const res = await fetch(`${baseUrl}/api/approval-requests/${created.id}`);
447
+ expect(res.status).toBe(200);
448
+ const data = (await res.json()) as { approvalRequest: { id: string; title: string } };
449
+ expect(data.approvalRequest.id).toBe(created.id);
450
+ });
451
+ });
452
+
453
+ describe("HTTP: POST /api/approval-requests/:id/respond", () => {
454
+ test("approves a pending request", async () => {
455
+ const created = createApprovalRequest(makeApprovalData());
456
+
457
+ const res = await fetch(`${baseUrl}/api/approval-requests/${created.id}/respond`, {
458
+ method: "POST",
459
+ headers: { "Content-Type": "application/json" },
460
+ body: JSON.stringify({
461
+ responses: { q1: { approved: true } },
462
+ respondedBy: "tester",
463
+ }),
464
+ });
465
+
466
+ expect(res.status).toBe(200);
467
+ const data = (await res.json()) as {
468
+ approvalRequest: { status: string; resolvedBy: string };
469
+ };
470
+ expect(data.approvalRequest.status).toBe("approved");
471
+ expect(data.approvalRequest.resolvedBy).toBe("tester");
472
+ });
473
+
474
+ test("rejects when approval question has approved: false", async () => {
475
+ const created = createApprovalRequest(makeApprovalData());
476
+
477
+ const res = await fetch(`${baseUrl}/api/approval-requests/${created.id}/respond`, {
478
+ method: "POST",
479
+ headers: { "Content-Type": "application/json" },
480
+ body: JSON.stringify({
481
+ responses: { q1: { approved: false } },
482
+ }),
483
+ });
484
+
485
+ expect(res.status).toBe(200);
486
+ const data = (await res.json()) as { approvalRequest: { status: string } };
487
+ expect(data.approvalRequest.status).toBe("rejected");
488
+ });
489
+
490
+ test("returns 404 for nonexistent request", async () => {
491
+ const res = await fetch(`${baseUrl}/api/approval-requests/${crypto.randomUUID()}/respond`, {
492
+ method: "POST",
493
+ headers: { "Content-Type": "application/json" },
494
+ body: JSON.stringify({ responses: {} }),
495
+ });
496
+ expect(res.status).toBe(404);
497
+ });
498
+
499
+ test("returns 409 for already-resolved request", async () => {
500
+ const created = createApprovalRequest(makeApprovalData());
501
+ resolveApprovalRequest(created.id, { status: "approved" });
502
+
503
+ const res = await fetch(`${baseUrl}/api/approval-requests/${created.id}/respond`, {
504
+ method: "POST",
505
+ headers: { "Content-Type": "application/json" },
506
+ body: JSON.stringify({ responses: { q1: { approved: true } } }),
507
+ });
508
+ expect(res.status).toBe(409);
509
+ });
510
+
511
+ test("approves when there are no approval-type questions", async () => {
512
+ const created = createApprovalRequest(
513
+ makeApprovalData({
514
+ questions: [{ id: "q1", type: "text", label: "Comments" }],
515
+ }),
516
+ );
517
+
518
+ const res = await fetch(`${baseUrl}/api/approval-requests/${created.id}/respond`, {
519
+ method: "POST",
520
+ headers: { "Content-Type": "application/json" },
521
+ body: JSON.stringify({ responses: { q1: "Looks good" } }),
522
+ });
523
+
524
+ expect(res.status).toBe(200);
525
+ const data = (await res.json()) as { approvalRequest: { status: string } };
526
+ // No approval-type questions means default is "approved"
527
+ expect(data.approvalRequest.status).toBe("approved");
528
+ });
529
+ });
530
+
531
+ // ─── HITL Executor ──────────────────────────────────────────
532
+
533
+ describe("HumanInTheLoopExecutor", () => {
534
+ const mockDeps: ExecutorDependencies = {
535
+ db: {
536
+ createApprovalRequest,
537
+ getApprovalRequestByStepId,
538
+ } as unknown as typeof import("../be/db"),
539
+ eventBus: { emit: () => {}, on: () => {}, off: () => {} },
540
+ interpolate: (template: string) => template,
541
+ };
542
+
543
+ const mockMeta: ExecutorMeta = {
544
+ runId: "00000000-0000-0000-0000-000000000010",
545
+ stepId: crypto.randomUUID(),
546
+ nodeId: "hitl-node",
547
+ workflowId: "00000000-0000-0000-0000-000000000012",
548
+ dryRun: false,
549
+ };
550
+
551
+ function _executorInput(
552
+ config: Record<string, unknown>,
553
+ context: Record<string, unknown> = {},
554
+ ): ExecutorInput {
555
+ return { config, context, meta: mockMeta };
556
+ }
557
+
558
+ test("has correct type and mode", () => {
559
+ const executor = new HumanInTheLoopExecutor(mockDeps);
560
+ expect(executor.type).toBe("human-in-the-loop");
561
+ expect(executor.mode).toBe("async");
562
+ });
563
+
564
+ test("creates approval request and returns async marker", async () => {
565
+ const stepId = crypto.randomUUID();
566
+ const meta = { ...mockMeta, stepId };
567
+ const executor = new HumanInTheLoopExecutor(mockDeps);
568
+
569
+ const result = await executor.run({
570
+ config: {
571
+ title: "Deploy approval",
572
+ questions: [{ id: "q1", type: "approval", label: "Approve?", required: true }],
573
+ approvers: { policy: "any" },
574
+ },
575
+ context: {},
576
+ meta,
577
+ });
578
+
579
+ expect(result.status).toBe("success");
580
+ // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
581
+ expect((result as any).async).toBe(true);
582
+ // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
583
+ expect((result as any).waitFor).toBe("approval.resolved");
584
+ // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
585
+ expect((result as any).correlationId).toBeTruthy();
586
+
587
+ // Verify the request was created in DB
588
+ const created = getApprovalRequestByStepId(stepId);
589
+ expect(created).not.toBeNull();
590
+ expect(created!.title).toBe("Deploy approval");
591
+ expect(created!.workflowRunStepId).toBe(stepId);
592
+ });
593
+
594
+ test("idempotency: returns async marker for pending existing request", async () => {
595
+ const stepId = crypto.randomUUID();
596
+ // Pre-create an approval request for this step
597
+ const existingId = crypto.randomUUID();
598
+ createApprovalRequest({
599
+ id: existingId,
600
+ title: "Pre-existing",
601
+ questions: [{ id: "q1", type: "approval", label: "Approve?" }],
602
+ approvers: { policy: "any" },
603
+ workflowRunId: mockMeta.runId,
604
+ workflowRunStepId: stepId,
605
+ });
606
+
607
+ const executor = new HumanInTheLoopExecutor(mockDeps);
608
+ const result = await executor.run({
609
+ config: {
610
+ title: "Deploy approval",
611
+ questions: [{ id: "q1", type: "approval", label: "Approve?" }],
612
+ approvers: { policy: "any" },
613
+ },
614
+ context: {},
615
+ meta: { ...mockMeta, stepId },
616
+ });
617
+
618
+ expect(result.status).toBe("success");
619
+ // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
620
+ expect((result as any).async).toBe(true);
621
+ // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
622
+ expect((result as any).correlationId).toBe(existingId);
623
+ });
624
+
625
+ test("idempotency: returns resolved result for completed request", async () => {
626
+ const stepId = crypto.randomUUID();
627
+ const existingId = crypto.randomUUID();
628
+ createApprovalRequest({
629
+ id: existingId,
630
+ title: "Already resolved",
631
+ questions: [{ id: "q1", type: "approval", label: "Approve?" }],
632
+ approvers: { policy: "any" },
633
+ workflowRunId: mockMeta.runId,
634
+ workflowRunStepId: stepId,
635
+ });
636
+ resolveApprovalRequest(existingId, {
637
+ status: "approved",
638
+ responses: { q1: { approved: true } },
639
+ });
640
+
641
+ const executor = new HumanInTheLoopExecutor(mockDeps);
642
+ const result = await executor.run({
643
+ config: {
644
+ title: "Deploy approval",
645
+ questions: [{ id: "q1", type: "approval", label: "Approve?" }],
646
+ approvers: { policy: "any" },
647
+ },
648
+ context: {},
649
+ meta: { ...mockMeta, stepId },
650
+ });
651
+
652
+ expect(result.status).toBe("success");
653
+ // biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
654
+ expect((result as any).async).toBeUndefined();
655
+ expect(result.output).toBeDefined();
656
+ expect(result.output!.requestId).toBe(existingId);
657
+ expect(result.output!.status).toBe("approved");
658
+ expect(result.nextPort).toBe("approved");
659
+ });
660
+
661
+ test("idempotency: returns rejected result with correct nextPort", async () => {
662
+ const stepId = crypto.randomUUID();
663
+ const existingId = crypto.randomUUID();
664
+ createApprovalRequest({
665
+ id: existingId,
666
+ title: "Rejected request",
667
+ questions: [{ id: "q1", type: "approval", label: "Approve?" }],
668
+ approvers: { policy: "any" },
669
+ workflowRunId: mockMeta.runId,
670
+ workflowRunStepId: stepId,
671
+ });
672
+ resolveApprovalRequest(existingId, {
673
+ status: "rejected",
674
+ responses: { q1: { approved: false } },
675
+ });
676
+
677
+ const executor = new HumanInTheLoopExecutor(mockDeps);
678
+ const result = await executor.run({
679
+ config: {
680
+ title: "Deploy approval",
681
+ questions: [{ id: "q1", type: "approval", label: "Approve?" }],
682
+ approvers: { policy: "any" },
683
+ },
684
+ context: {},
685
+ meta: { ...mockMeta, stepId },
686
+ });
687
+
688
+ expect(result.status).toBe("success");
689
+ expect(result.output!.status).toBe("rejected");
690
+ expect(result.nextPort).toBe("rejected");
691
+ });
692
+
693
+ test("stores timeout config in request", async () => {
694
+ const stepId = crypto.randomUUID();
695
+ const executor = new HumanInTheLoopExecutor(mockDeps);
696
+
697
+ await executor.run({
698
+ config: {
699
+ title: "Timed approval",
700
+ questions: [{ id: "q1", type: "approval", label: "Approve?" }],
701
+ approvers: { policy: "any" },
702
+ timeout: { seconds: 7200, action: "reject" },
703
+ },
704
+ context: {},
705
+ meta: { ...mockMeta, stepId },
706
+ });
707
+
708
+ const created = getApprovalRequestByStepId(stepId);
709
+ expect(created).not.toBeNull();
710
+ expect(created!.timeoutSeconds).toBe(7200);
711
+ expect(created!.expiresAt).toBeTruthy();
712
+ });
713
+
714
+ test("validates config schema", async () => {
715
+ const executor = new HumanInTheLoopExecutor(mockDeps);
716
+
717
+ const result = await executor.run({
718
+ config: {
719
+ // Missing required 'title' field
720
+ questions: [{ id: "q1", type: "approval", label: "Approve?" }],
721
+ approvers: { policy: "any" },
722
+ },
723
+ context: {},
724
+ meta: mockMeta,
725
+ });
726
+
727
+ expect(result.status).toBe("failed");
728
+ });
729
+
730
+ test("validates config schema: empty questions array", async () => {
731
+ const executor = new HumanInTheLoopExecutor(mockDeps);
732
+
733
+ const result = await executor.run({
734
+ config: {
735
+ title: "Empty questions",
736
+ questions: [],
737
+ approvers: { policy: "any" },
738
+ },
739
+ context: {},
740
+ meta: mockMeta,
741
+ });
742
+
743
+ expect(result.status).toBe("failed");
744
+ });
745
+ });
746
+
747
+ // ─── Follow-up task flow ─────────────────────────────────────
748
+ describe("Follow-up task: Slack metadata inheritance", () => {
749
+ test("sourceTaskId is stored and returned on resolved approval request", () => {
750
+ // Create a source task with Slack metadata
751
+ const agent = createAgent({
752
+ name: "test-follow-up-agent",
753
+ isLead: false,
754
+ status: "idle",
755
+ });
756
+ const sourceTask = createTaskExtended("original task with slack context", {
757
+ agentId: agent.id,
758
+ source: "mcp",
759
+ slackChannelId: "C_TEST_CHANNEL",
760
+ slackThreadTs: "1234567890.123456",
761
+ slackUserId: "U_TEST_USER",
762
+ });
763
+
764
+ // Create approval request linked to source task
765
+ const approvalData = makeApprovalData({ sourceTaskId: sourceTask.id });
766
+ const approval = createApprovalRequest(approvalData);
767
+ expect(approval.sourceTaskId).toBe(sourceTask.id);
768
+
769
+ // Resolve it
770
+ const resolved = resolveApprovalRequest(approval.id, {
771
+ status: "approved",
772
+ responses: { q1: { approved: true } },
773
+ });
774
+ expect(resolved).not.toBeNull();
775
+ expect(resolved!.sourceTaskId).toBe(sourceTask.id);
776
+ });
777
+
778
+ test("follow-up task inherits Slack metadata from source task via parentTaskId", () => {
779
+ const agent = createAgent({
780
+ name: "test-slack-inherit-agent",
781
+ isLead: false,
782
+ status: "idle",
783
+ });
784
+ const sourceTask = createTaskExtended("source task", {
785
+ agentId: agent.id,
786
+ source: "mcp",
787
+ slackChannelId: "C_FOLLOW_UP",
788
+ slackThreadTs: "9999999999.000000",
789
+ slackUserId: "U_FOLLOW_UP",
790
+ });
791
+
792
+ // Simulate what the respond handler does: create follow-up with parentTaskId
793
+ const followUp = createTaskExtended("follow-up task text", {
794
+ agentId: sourceTask.agentId ?? undefined,
795
+ parentTaskId: sourceTask.id,
796
+ source: "system",
797
+ taskType: "hitl-follow-up",
798
+ tags: ["hitl", "follow-up"],
799
+ // Explicit Slack metadata (as the handler now does)
800
+ slackChannelId: sourceTask.slackChannelId ?? undefined,
801
+ slackThreadTs: sourceTask.slackThreadTs ?? undefined,
802
+ slackUserId: sourceTask.slackUserId ?? undefined,
803
+ });
804
+
805
+ expect(followUp.slackChannelId).toBe("C_FOLLOW_UP");
806
+ expect(followUp.slackThreadTs).toBe("9999999999.000000");
807
+ expect(followUp.slackUserId).toBe("U_FOLLOW_UP");
808
+ expect(followUp.parentTaskId).toBe(sourceTask.id);
809
+ expect(followUp.taskType).toBe("hitl-follow-up");
810
+ });
811
+
812
+ test("follow-up task inherits Slack metadata even without explicit pass (auto-inheritance)", () => {
813
+ const agent = createAgent({
814
+ name: "test-auto-inherit-agent",
815
+ isLead: false,
816
+ status: "idle",
817
+ });
818
+ const sourceTask = createTaskExtended("source task auto", {
819
+ agentId: agent.id,
820
+ source: "mcp",
821
+ slackChannelId: "C_AUTO",
822
+ slackThreadTs: "1111111111.000000",
823
+ slackUserId: "U_AUTO",
824
+ });
825
+
826
+ // Without explicit Slack metadata — relies on auto-inheritance from parentTaskId
827
+ const followUp = createTaskExtended("auto-inherit follow-up", {
828
+ agentId: sourceTask.agentId ?? undefined,
829
+ parentTaskId: sourceTask.id,
830
+ source: "system",
831
+ taskType: "hitl-follow-up",
832
+ });
833
+
834
+ expect(followUp.slackChannelId).toBe("C_AUTO");
835
+ expect(followUp.slackThreadTs).toBe("1111111111.000000");
836
+ expect(followUp.slackUserId).toBe("U_AUTO");
837
+ });
838
+
839
+ test("no follow-up for workflow-linked requests (workflowRunId set)", () => {
840
+ const approvalData = makeApprovalData({
841
+ sourceTaskId: crypto.randomUUID(),
842
+ workflowRunId: crypto.randomUUID(),
843
+ workflowRunStepId: crypto.randomUUID(),
844
+ });
845
+ const approval = createApprovalRequest(approvalData);
846
+
847
+ // The condition in the handler is: !updated.workflowRunId && updated.sourceTaskId
848
+ // With workflowRunId set, this should be false
849
+ expect(approval.workflowRunId).toBeTruthy();
850
+ expect(approval.sourceTaskId).toBeTruthy();
851
+ // The handler would NOT create a follow-up task here
852
+ expect(!approval.workflowRunId && approval.sourceTaskId).toBe(false);
853
+ });
854
+
855
+ test("no follow-up when sourceTaskId is missing", () => {
856
+ const approvalData = makeApprovalData(); // no sourceTaskId
857
+ const approval = createApprovalRequest(approvalData);
858
+
859
+ expect(approval.sourceTaskId).toBeNull();
860
+ // The handler condition would be false
861
+ expect(!approval.workflowRunId && approval.sourceTaskId).toBeFalsy();
862
+ });
863
+ });
864
+
865
+ // ─── Server-side sourceTaskId fallback ───────────────────────
866
+ describe("getAgentCurrentTask fallback for sourceTaskId", () => {
867
+ test("returns the most recent in-progress task for an agent", () => {
868
+ const agent = createAgent({
869
+ name: "test-current-task-agent",
870
+ isLead: true,
871
+ status: "idle",
872
+ });
873
+
874
+ // Create a task and set it to in_progress
875
+ const task = createTaskExtended("lead agent task", {
876
+ agentId: agent.id,
877
+ source: "mcp",
878
+ });
879
+ startTask(task.id);
880
+
881
+ const currentTask = getAgentCurrentTask(agent.id);
882
+ expect(currentTask).not.toBeNull();
883
+ expect(currentTask!.id).toBe(task.id);
884
+ });
885
+
886
+ test("returns null when agent has no in-progress tasks", () => {
887
+ const agent = createAgent({
888
+ name: "test-no-task-agent",
889
+ isLead: true,
890
+ status: "idle",
891
+ });
892
+
893
+ const currentTask = getAgentCurrentTask(agent.id);
894
+ expect(currentTask).toBeNull();
895
+ });
896
+
897
+ test("fallback sourceTaskId resolves correctly for approval request", () => {
898
+ const agent = createAgent({
899
+ name: "test-fallback-agent",
900
+ isLead: true,
901
+ status: "idle",
902
+ });
903
+ const task = createTaskExtended("lead task calling request-human-input", {
904
+ agentId: agent.id,
905
+ source: "mcp",
906
+ slackChannelId: "C_LEAD_CHANNEL",
907
+ slackThreadTs: "1111111111.000000",
908
+ slackUserId: "U_LEAD_USER",
909
+ });
910
+ startTask(task.id);
911
+
912
+ // Simulate what the fixed request-human-input tool does:
913
+ // sourceTaskId from header is missing, so fall back to agent's current task
914
+ const headerSourceTaskId: string | undefined = undefined;
915
+ let sourceTaskId = headerSourceTaskId;
916
+ if (!sourceTaskId) {
917
+ const currentTask = getAgentCurrentTask(agent.id);
918
+ if (currentTask) {
919
+ sourceTaskId = currentTask.id;
920
+ }
921
+ }
922
+
923
+ const approval = createApprovalRequest(makeApprovalData({ sourceTaskId }));
924
+ expect(approval.sourceTaskId).toBe(task.id);
925
+ });
926
+ });
927
+
928
+ describe("updateApprovalRequestNotifications", () => {
929
+ test("stores messageTs back in notification channels", () => {
930
+ const channels = [
931
+ { channel: "slack", target: "C12345" },
932
+ { channel: "email", target: "user@example.com" },
933
+ ];
934
+ const approval = createApprovalRequest(makeApprovalData({ notificationChannels: channels }));
935
+ expect(approval.notificationChannels).toEqual(channels);
936
+
937
+ const updatedChannels = [
938
+ { channel: "slack", target: "C12345", messageTs: "1234567890.123456" },
939
+ { channel: "email", target: "user@example.com" },
940
+ ];
941
+ updateApprovalRequestNotifications(approval.id, updatedChannels);
942
+
943
+ const fetched = getApprovalRequestById(approval.id);
944
+ expect(fetched).not.toBeNull();
945
+ expect(fetched!.notificationChannels).toEqual(updatedChannels);
946
+ });
947
+ });
948
+ });