@desplega.ai/agent-swarm 1.52.0 → 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.
- package/README.md +131 -0
- package/package.json +3 -1
- package/src/be/db.ts +27 -0
- package/src/be/migrations/runner.ts +4 -4
- package/src/commands/runner.ts +166 -14
- package/src/http/agents.ts +29 -0
- package/src/http/approval-requests.ts +63 -0
- package/src/http/index.ts +22 -2
- package/src/http/poll.ts +15 -0
- package/src/http/tasks.ts +94 -0
- package/src/linear/outbound.ts +12 -12
- package/src/providers/claude-adapter.ts +19 -3
- package/src/scheduler/scheduler.ts +24 -1
- package/src/slack/blocks.ts +1 -1
- package/src/tests/approval-requests.test.ts +214 -1
- package/src/tests/skill-sync.test.ts +1 -1
- package/src/tests/slack-blocks.test.ts +3 -2
- package/src/tests/structured-output.test.ts +1 -0
- package/src/tests/tool-call-progress.test.ts +207 -0
- package/src/tests/tool-registrar-no-input.test.ts +114 -0
- package/src/tests/update-profile-auth.test.ts +1 -0
- package/src/tools/request-human-input.ts +14 -3
- package/src/tools/store-progress.ts +31 -0
- package/src/tools/templates.ts +28 -0
- package/src/tools/utils.ts +9 -7
- package/src/workflows/executors/human-in-the-loop.ts +115 -2
package/src/http/poll.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { ensure } from "@desplega.ai/business-use";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import {
|
|
4
5
|
claimMentions,
|
|
@@ -128,6 +129,20 @@ export async function handlePoll(
|
|
|
128
129
|
if (pendingTask) {
|
|
129
130
|
// Mark task as in_progress immediately to prevent duplicate polling
|
|
130
131
|
startTask(pendingTask.id);
|
|
132
|
+
|
|
133
|
+
ensure({
|
|
134
|
+
id: "started",
|
|
135
|
+
flow: "task",
|
|
136
|
+
runId: pendingTask.id,
|
|
137
|
+
depIds: ["created"],
|
|
138
|
+
data: {
|
|
139
|
+
taskId: pendingTask.id,
|
|
140
|
+
agentId: myAgentId,
|
|
141
|
+
previousStatus: pendingTask.status,
|
|
142
|
+
},
|
|
143
|
+
validator: (data) => data.previousStatus === "pending",
|
|
144
|
+
});
|
|
145
|
+
|
|
131
146
|
return {
|
|
132
147
|
trigger: {
|
|
133
148
|
type: "task_assigned",
|
package/src/http/tasks.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { ensure } from "@desplega.ai/business-use";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import {
|
|
4
5
|
cancelTask,
|
|
@@ -232,6 +233,24 @@ export async function handleTasks(
|
|
|
232
233
|
source: (parsed.body.source as import("../types").AgentTaskSource) || "api",
|
|
233
234
|
outputSchema: parsed.body.outputSchema || undefined,
|
|
234
235
|
});
|
|
236
|
+
|
|
237
|
+
ensure({
|
|
238
|
+
id: "created",
|
|
239
|
+
flow: "task",
|
|
240
|
+
runId: task.id,
|
|
241
|
+
data: {
|
|
242
|
+
taskId: task.id,
|
|
243
|
+
agentId: task.agentId,
|
|
244
|
+
source: parsed.body.source || "api",
|
|
245
|
+
status: task.status,
|
|
246
|
+
task: task.task.slice(0, 200),
|
|
247
|
+
priority: task.priority,
|
|
248
|
+
tags: task.tags,
|
|
249
|
+
parentTaskId: task.parentTaskId,
|
|
250
|
+
epicId: task.epicId,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
235
254
|
json(res, task, 201);
|
|
236
255
|
} catch (error) {
|
|
237
256
|
console.error("[HTTP] Failed to create task:", error);
|
|
@@ -291,6 +310,36 @@ export async function handleTasks(
|
|
|
291
310
|
return true;
|
|
292
311
|
}
|
|
293
312
|
|
|
313
|
+
if (task.status === "pending") {
|
|
314
|
+
ensure({
|
|
315
|
+
id: "cancelled_pending",
|
|
316
|
+
flow: "task",
|
|
317
|
+
runId: parsed.params.id,
|
|
318
|
+
depIds: ["created"],
|
|
319
|
+
data: {
|
|
320
|
+
taskId: parsed.params.id,
|
|
321
|
+
agentId: task.agentId,
|
|
322
|
+
previousStatus: task.status,
|
|
323
|
+
reason,
|
|
324
|
+
},
|
|
325
|
+
validator: (data) => data.previousStatus === "pending",
|
|
326
|
+
});
|
|
327
|
+
} else {
|
|
328
|
+
ensure({
|
|
329
|
+
id: "cancelled_in_progress",
|
|
330
|
+
flow: "task",
|
|
331
|
+
runId: parsed.params.id,
|
|
332
|
+
depIds: ["started"],
|
|
333
|
+
data: {
|
|
334
|
+
taskId: parsed.params.id,
|
|
335
|
+
agentId: task.agentId,
|
|
336
|
+
previousStatus: task.status,
|
|
337
|
+
reason,
|
|
338
|
+
},
|
|
339
|
+
validator: (data) => data.previousStatus === "in_progress",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
294
343
|
if (task.agentId) {
|
|
295
344
|
updateAgentStatusFromCapacity(task.agentId);
|
|
296
345
|
}
|
|
@@ -386,6 +435,25 @@ export async function handleTasks(
|
|
|
386
435
|
return true;
|
|
387
436
|
}
|
|
388
437
|
|
|
438
|
+
if (result.task && !("alreadyFinished" in result && result.alreadyFinished)) {
|
|
439
|
+
const finishEventId = parsed.body.status === "completed" ? "completed" : "failed";
|
|
440
|
+
ensure({
|
|
441
|
+
id: finishEventId,
|
|
442
|
+
flow: "task",
|
|
443
|
+
runId: parsed.params.id,
|
|
444
|
+
depIds: ["started"],
|
|
445
|
+
data: {
|
|
446
|
+
taskId: parsed.params.id,
|
|
447
|
+
agentId: myAgentId,
|
|
448
|
+
previousStatus: "in_progress",
|
|
449
|
+
...(finishEventId === "completed"
|
|
450
|
+
? { hasOutput: !!parsed.body.output }
|
|
451
|
+
: { failureReason: parsed.body.failureReason }),
|
|
452
|
+
},
|
|
453
|
+
validator: (data) => data.previousStatus === "in_progress",
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
389
457
|
json(res, {
|
|
390
458
|
success: true,
|
|
391
459
|
alreadyFinished: "alreadyFinished" in result ? result.alreadyFinished : false,
|
|
@@ -430,6 +498,19 @@ export async function handleTasks(
|
|
|
430
498
|
return true;
|
|
431
499
|
}
|
|
432
500
|
|
|
501
|
+
ensure({
|
|
502
|
+
id: "paused",
|
|
503
|
+
flow: "task",
|
|
504
|
+
runId: parsed.params.id,
|
|
505
|
+
depIds: ["started"],
|
|
506
|
+
data: {
|
|
507
|
+
taskId: parsed.params.id,
|
|
508
|
+
agentId: task.agentId,
|
|
509
|
+
previousStatus: task.status,
|
|
510
|
+
},
|
|
511
|
+
validator: (data) => data.previousStatus === "in_progress",
|
|
512
|
+
});
|
|
513
|
+
|
|
433
514
|
json(res, { success: true, task: pausedTask });
|
|
434
515
|
return true;
|
|
435
516
|
}
|
|
@@ -460,6 +541,19 @@ export async function handleTasks(
|
|
|
460
541
|
return true;
|
|
461
542
|
}
|
|
462
543
|
|
|
544
|
+
ensure({
|
|
545
|
+
id: "resumed",
|
|
546
|
+
flow: "task",
|
|
547
|
+
runId: parsed.params.id,
|
|
548
|
+
depIds: ["paused"],
|
|
549
|
+
data: {
|
|
550
|
+
taskId: parsed.params.id,
|
|
551
|
+
agentId: task.agentId,
|
|
552
|
+
previousStatus: task.status,
|
|
553
|
+
},
|
|
554
|
+
validator: (data) => data.previousStatus === "paused",
|
|
555
|
+
});
|
|
556
|
+
|
|
463
557
|
json(res, { success: true, task: resumedTask });
|
|
464
558
|
return true;
|
|
465
559
|
}
|
package/src/linear/outbound.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
|
|
2
2
|
import { workflowEventBus } from "../workflows/event-bus";
|
|
3
3
|
import { getLinearClient } from "./client";
|
|
4
|
-
import {
|
|
5
|
-
endAgentSession,
|
|
6
|
-
postAgentSessionAction,
|
|
7
|
-
postAgentSessionThought,
|
|
8
|
-
taskSessionMap,
|
|
9
|
-
} from "./sync";
|
|
4
|
+
import { endAgentSession, postAgentSessionAction, taskSessionMap } from "./sync";
|
|
10
5
|
|
|
11
6
|
let subscribed = false;
|
|
12
7
|
|
|
@@ -60,8 +55,9 @@ async function handleTaskProgress(data: unknown): Promise<void> {
|
|
|
60
55
|
const sessionId = taskSessionMap.get(taskId);
|
|
61
56
|
if (!sessionId) return;
|
|
62
57
|
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
// Use 'action' activity type — Linear renders it as a structured tool invocation card
|
|
59
|
+
postAgentSessionAction(sessionId, progress).catch((err) => {
|
|
60
|
+
console.error(`[Linear Outbound] Failed to post progress action for task ${taskId}:`, err);
|
|
65
61
|
});
|
|
66
62
|
}
|
|
67
63
|
|
|
@@ -75,7 +71,9 @@ async function handleTaskCompleted(data: unknown): Promise<void> {
|
|
|
75
71
|
if (shouldSkipForLoopPrevention(sync)) return;
|
|
76
72
|
|
|
77
73
|
const sessionId = taskSessionMap.get(taskId);
|
|
78
|
-
const body = output
|
|
74
|
+
const body = output
|
|
75
|
+
? `Task completed.\n\n+++ Output\n${output.slice(0, 2000)}\n+++`
|
|
76
|
+
: "Task completed.";
|
|
79
77
|
|
|
80
78
|
// Prefer AgentSession activity (shows in the agent panel) over issue comment (avoids duplication)
|
|
81
79
|
if (sessionId) {
|
|
@@ -93,7 +91,7 @@ async function handleTaskCompleted(data: unknown): Promise<void> {
|
|
|
93
91
|
return;
|
|
94
92
|
}
|
|
95
93
|
const comment = output
|
|
96
|
-
? `Task completed by swarm agent.\n\
|
|
94
|
+
? `Task completed by swarm agent.\n\n+++ Output\n${output.slice(0, 2000)}\n+++`
|
|
97
95
|
: "Task completed by swarm agent.";
|
|
98
96
|
await client.createComment({ issueId: sync.externalId, body: comment });
|
|
99
97
|
console.log(`[Linear Outbound] Posted completion comment for task ${taskId}`);
|
|
@@ -121,7 +119,9 @@ async function handleTaskFailed(data: unknown): Promise<void> {
|
|
|
121
119
|
if (shouldSkipForLoopPrevention(sync)) return;
|
|
122
120
|
|
|
123
121
|
const sessionId = taskSessionMap.get(taskId);
|
|
124
|
-
const body = failureReason
|
|
122
|
+
const body = failureReason
|
|
123
|
+
? `Task failed.\n\n+++ Error Details\n${failureReason.slice(0, 2000)}\n+++`
|
|
124
|
+
: "Task failed.";
|
|
125
125
|
|
|
126
126
|
// Prefer AgentSession error activity over issue comment (avoids duplication)
|
|
127
127
|
if (sessionId) {
|
|
@@ -139,7 +139,7 @@ async function handleTaskFailed(data: unknown): Promise<void> {
|
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
141
141
|
const comment = failureReason
|
|
142
|
-
? `Task failed.\n\
|
|
142
|
+
? `Task failed.\n\n+++ Error Details\n${failureReason.slice(0, 2000)}\n+++`
|
|
143
143
|
: "Task failed.";
|
|
144
144
|
await client.createComment({ issueId: sync.externalId, body: comment });
|
|
145
145
|
console.log(`[Linear Outbound] Posted failure comment for task ${taskId}`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { unlink, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { validateClaudeCredentials } from "../utils/credentials";
|
|
4
4
|
import {
|
|
5
5
|
parseStderrForErrors,
|
|
@@ -48,10 +48,26 @@ async function cleanupTaskFile(pid: number): Promise<void> {
|
|
|
48
48
|
* Returns the path to the per-session config, or null if no config exists.
|
|
49
49
|
*/
|
|
50
50
|
async function createSessionMcpConfig(cwd: string, taskId: string): Promise<string | null> {
|
|
51
|
-
|
|
51
|
+
// Walk up from cwd to find .mcp.json (mirrors Claude CLI's project-level config discovery).
|
|
52
|
+
// In Docker, .mcp.json lives at /workspace/.mcp.json but tasks often run with cwd set to
|
|
53
|
+
// a subdirectory like /workspace/repos/<repo>, so a single-directory check misses it.
|
|
54
|
+
let searchDir = cwd;
|
|
55
|
+
let mcpJsonPath: string | null = null;
|
|
56
|
+
while (true) {
|
|
57
|
+
const candidate = join(searchDir, ".mcp.json");
|
|
58
|
+
if (await Bun.file(candidate).exists()) {
|
|
59
|
+
mcpJsonPath = candidate;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
const parent = dirname(searchDir);
|
|
63
|
+
if (parent === searchDir) break; // reached filesystem root
|
|
64
|
+
searchDir = parent;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!mcpJsonPath) return null;
|
|
68
|
+
|
|
52
69
|
try {
|
|
53
70
|
const file = Bun.file(mcpJsonPath);
|
|
54
|
-
if (!(await file.exists())) return null;
|
|
55
71
|
|
|
56
72
|
const config = await file.json();
|
|
57
73
|
const servers = config?.mcpServers;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensure } from "@desplega.ai/business-use";
|
|
1
2
|
import { CronExpressionParser } from "cron-parser";
|
|
2
3
|
import {
|
|
3
4
|
createTaskExtended,
|
|
@@ -239,7 +240,11 @@ async function executeSchedule(schedule: ScheduledTask): Promise<void> {
|
|
|
239
240
|
* @param registry ExecutorRegistry for triggering workflows linked to schedules
|
|
240
241
|
* @param intervalMs Polling interval in milliseconds (default: 10000)
|
|
241
242
|
*/
|
|
242
|
-
export function startScheduler(
|
|
243
|
+
export function startScheduler(
|
|
244
|
+
registry: ExecutorRegistry,
|
|
245
|
+
intervalMs = 10000,
|
|
246
|
+
opts?: { runId?: string },
|
|
247
|
+
): void {
|
|
243
248
|
if (schedulerInterval) {
|
|
244
249
|
console.log("[Scheduler] Already running");
|
|
245
250
|
return;
|
|
@@ -254,6 +259,24 @@ export function startScheduler(registry: ExecutorRegistry, intervalMs = 10000):
|
|
|
254
259
|
schedulerInterval = setInterval(async () => {
|
|
255
260
|
await processSchedules();
|
|
256
261
|
}, intervalMs);
|
|
262
|
+
|
|
263
|
+
ensure({
|
|
264
|
+
id: "scheduler_started",
|
|
265
|
+
flow: "api",
|
|
266
|
+
runId: opts?.runId ?? "",
|
|
267
|
+
depIds: ["listen"],
|
|
268
|
+
data: {},
|
|
269
|
+
// biome-ignore lint/correctness/noEmptyPattern: data unused, ctx needed
|
|
270
|
+
filter: ({}, ctx) => {
|
|
271
|
+
const start = ctx.deps.find((d) => d.id === "listen");
|
|
272
|
+
return !!start && start.data?.capabilities?.includes("scheduling");
|
|
273
|
+
},
|
|
274
|
+
// biome-ignore lint/correctness/noEmptyPattern: data unused, ctx needed
|
|
275
|
+
validator: ({}, ctx) => {
|
|
276
|
+
const start = ctx.deps.find((d) => d.id === "listen");
|
|
277
|
+
return !!start && start.data?.capabilities?.includes("scheduling");
|
|
278
|
+
},
|
|
279
|
+
});
|
|
257
280
|
}
|
|
258
281
|
|
|
259
282
|
/**
|
package/src/slack/blocks.ts
CHANGED
|
@@ -179,7 +179,7 @@ export function buildProgressBlocks(opts: {
|
|
|
179
179
|
}): SlackBlock[] {
|
|
180
180
|
const shortId = opts.taskId.slice(0, 8);
|
|
181
181
|
return [
|
|
182
|
-
sectionBlock(
|
|
182
|
+
sectionBlock(`*${opts.agentName}* (\`${shortId}\`): ${opts.progress}`),
|
|
183
183
|
cancelActionBlock(opts.taskId),
|
|
184
184
|
];
|
|
185
185
|
}
|
|
@@ -3,13 +3,18 @@ import { unlink } from "node:fs/promises";
|
|
|
3
3
|
import { createServer as createHttpServer, type Server } from "node:http";
|
|
4
4
|
import {
|
|
5
5
|
closeDb,
|
|
6
|
+
createAgent,
|
|
6
7
|
createApprovalRequest,
|
|
8
|
+
createTaskExtended,
|
|
9
|
+
getAgentCurrentTask,
|
|
7
10
|
getApprovalRequestById,
|
|
8
11
|
getApprovalRequestByStepId,
|
|
9
12
|
getExpiredPendingApprovals,
|
|
10
13
|
initDb,
|
|
11
14
|
listApprovalRequests,
|
|
12
15
|
resolveApprovalRequest,
|
|
16
|
+
startTask,
|
|
17
|
+
updateApprovalRequestNotifications,
|
|
13
18
|
} from "../be/db";
|
|
14
19
|
import type { ExecutorMeta } from "../types";
|
|
15
20
|
import type { ExecutorDependencies, ExecutorInput } from "../workflows/executors/base";
|
|
@@ -543,7 +548,7 @@ describe("Approval Requests", () => {
|
|
|
543
548
|
dryRun: false,
|
|
544
549
|
};
|
|
545
550
|
|
|
546
|
-
function
|
|
551
|
+
function _executorInput(
|
|
547
552
|
config: Record<string, unknown>,
|
|
548
553
|
context: Record<string, unknown> = {},
|
|
549
554
|
): ExecutorInput {
|
|
@@ -572,8 +577,11 @@ describe("Approval Requests", () => {
|
|
|
572
577
|
});
|
|
573
578
|
|
|
574
579
|
expect(result.status).toBe("success");
|
|
580
|
+
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
575
581
|
expect((result as any).async).toBe(true);
|
|
582
|
+
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
576
583
|
expect((result as any).waitFor).toBe("approval.resolved");
|
|
584
|
+
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
577
585
|
expect((result as any).correlationId).toBeTruthy();
|
|
578
586
|
|
|
579
587
|
// Verify the request was created in DB
|
|
@@ -608,7 +616,9 @@ describe("Approval Requests", () => {
|
|
|
608
616
|
});
|
|
609
617
|
|
|
610
618
|
expect(result.status).toBe("success");
|
|
619
|
+
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
611
620
|
expect((result as any).async).toBe(true);
|
|
621
|
+
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
612
622
|
expect((result as any).correlationId).toBe(existingId);
|
|
613
623
|
});
|
|
614
624
|
|
|
@@ -640,6 +650,7 @@ describe("Approval Requests", () => {
|
|
|
640
650
|
});
|
|
641
651
|
|
|
642
652
|
expect(result.status).toBe("success");
|
|
653
|
+
// biome-ignore lint/suspicious/noExplicitAny: test assertion on untyped executor result
|
|
643
654
|
expect((result as any).async).toBeUndefined();
|
|
644
655
|
expect(result.output).toBeDefined();
|
|
645
656
|
expect(result.output!.requestId).toBe(existingId);
|
|
@@ -732,4 +743,206 @@ describe("Approval Requests", () => {
|
|
|
732
743
|
expect(result.status).toBe("failed");
|
|
733
744
|
});
|
|
734
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
|
+
});
|
|
735
948
|
});
|
|
@@ -97,7 +97,7 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
test("skips complex skills", () => {
|
|
100
|
-
const
|
|
100
|
+
const _result = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
101
101
|
|
|
102
102
|
const complexDir = join(FAKE_HOME, ".claude", "skills", "complex-skill");
|
|
103
103
|
expect(existsSync(complexDir)).toBe(false);
|
|
@@ -145,9 +145,10 @@ describe("buildProgressBlocks", () => {
|
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
expect(blocks.length).toBe(2);
|
|
148
|
-
// Single line:
|
|
148
|
+
// Single line: *Gamma* (`aabbccdd`): Analyzing codebase...
|
|
149
|
+
// (no ⏳ prefix — progress strings now carry their own emoji)
|
|
149
150
|
expect(blocks[0].type).toBe("section");
|
|
150
|
-
expect(blocks[0].text.text).toContain("⏳");
|
|
151
|
+
expect(blocks[0].text.text).not.toContain("⏳");
|
|
151
152
|
expect(blocks[0].text.text).toContain("Gamma");
|
|
152
153
|
expect(blocks[0].text.text).toContain("aabbccdd");
|
|
153
154
|
expect(blocks[0].text.text).toContain("Analyzing codebase...");
|
|
@@ -233,6 +233,7 @@ describe("AgentTaskConfigSchema — outputSchema", () => {
|
|
|
233
233
|
test("accepts outputSchema in config", async () => {
|
|
234
234
|
const { AgentTaskExecutor } = await import("../workflows/executors/agent-task");
|
|
235
235
|
const executor = new AgentTaskExecutor({
|
|
236
|
+
// biome-ignore lint/suspicious/noExplicitAny: mock DB for test
|
|
236
237
|
db: {} as any,
|
|
237
238
|
eventBus: { emit: () => {}, on: () => {}, off: () => {} },
|
|
238
239
|
interpolate: (t: string) => t,
|