@desplega.ai/agent-swarm 1.84.0 → 1.85.0

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 (48) hide show
  1. package/README.md +48 -8
  2. package/openapi.json +5 -3
  3. package/package.json +1 -1
  4. package/src/be/db-queries/oauth.ts +33 -0
  5. package/src/be/db.ts +7 -1
  6. package/src/be/migrations/076_kapso_sender_user_backfill.sql +43 -0
  7. package/src/be/migrations/077_oauth_refresh_locks.sql +8 -0
  8. package/src/commands/context-preamble.ts +178 -0
  9. package/src/commands/runner.ts +87 -7
  10. package/src/http/index.ts +11 -3
  11. package/src/http/tasks.ts +17 -0
  12. package/src/http/users.ts +11 -3
  13. package/src/http/utils.ts +17 -0
  14. package/src/integrations/kapso/inbound.ts +36 -0
  15. package/src/oauth/ensure-token.ts +97 -11
  16. package/src/prompts/base-prompt.ts +15 -2
  17. package/src/prompts/session-templates.ts +26 -12
  18. package/src/providers/pi-mono-adapter.ts +44 -25
  19. package/src/server.ts +2 -0
  20. package/src/tasks/worker-follow-up.ts +82 -0
  21. package/src/tests/agentmail-sending-skill.test.ts +75 -0
  22. package/src/tests/agents-list-model-display.test.ts +45 -0
  23. package/src/tests/base-prompt.test.ts +90 -1
  24. package/src/tests/db-queries-oauth.test.ts +27 -0
  25. package/src/tests/ensure-token.test.ts +71 -0
  26. package/src/tests/http-log-scrubbing.test.ts +24 -0
  27. package/src/tests/http-users.test.ts +53 -0
  28. package/src/tests/kapso-inbound.test.ts +60 -1
  29. package/src/tests/kv-page-proxy.test.ts +1 -0
  30. package/src/tests/list-endpoint-slimming.test.ts +22 -1
  31. package/src/tests/oauth-access-token-tool.test.ts +138 -0
  32. package/src/tests/pagination-metrics.test.ts +4 -4
  33. package/src/tests/pi-mono-adapter.test.ts +37 -1
  34. package/src/tests/prompt-template-session.test.ts +13 -3
  35. package/src/tests/runner-context-preamble.test.ts +202 -0
  36. package/src/tests/runner-fallback-output.test.ts +118 -39
  37. package/src/tests/task-completion-idempotency.test.ts +89 -0
  38. package/src/tools/cancel-task.ts +13 -5
  39. package/src/tools/get-task-details.ts +18 -10
  40. package/src/tools/get-tasks.ts +9 -4
  41. package/src/tools/oauth-access-token.ts +118 -0
  42. package/src/tools/send-task.ts +9 -5
  43. package/src/tools/store-progress.ts +12 -77
  44. package/src/tools/task-action.ts +20 -10
  45. package/src/tools/tool-config.ts +2 -1
  46. package/src/types.ts +5 -0
  47. package/src/utils/secret-scrubber.ts +23 -0
  48. package/templates/skills/agentmail-sending/SKILL.md +148 -28
@@ -1,7 +1,12 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { createPiRuntimeAuth, PiMonoAdapter, resolveModel } from "../providers/pi-mono-adapter";
4
+ import {
5
+ createPiRuntimeAuth,
6
+ extractPiAssistantText,
7
+ PiMonoAdapter,
8
+ resolveModel,
9
+ } from "../providers/pi-mono-adapter";
5
10
 
6
11
  describe("PiMonoAdapter", () => {
7
12
  test("name is 'pi'", () => {
@@ -198,6 +203,37 @@ describe("createPiRuntimeAuth", () => {
198
203
  });
199
204
 
200
205
  describe("Pi-mono event normalization", () => {
206
+ test("extractPiAssistantText ignores user messages", () => {
207
+ const text = extractPiAssistantText({
208
+ role: "user",
209
+ content: "/skill:work-on-task task-123\n\nTask: hello",
210
+ });
211
+
212
+ expect(text).toBe("");
213
+ });
214
+
215
+ test("extractPiAssistantText extracts assistant text blocks", () => {
216
+ const text = extractPiAssistantText({
217
+ role: "assistant",
218
+ content: [
219
+ { type: "text", text: "Hello, " },
220
+ { type: "thinking", thinking: "hidden" },
221
+ { type: "text", text: "world!" },
222
+ ],
223
+ });
224
+
225
+ expect(text).toBe("Hello, world!");
226
+ });
227
+
228
+ test("extractPiAssistantText supports string assistant content", () => {
229
+ const text = extractPiAssistantText({
230
+ role: "assistant",
231
+ content: "Plain assistant output",
232
+ });
233
+
234
+ expect(text).toBe("Plain assistant output");
235
+ });
236
+
201
237
  test("message_update with text content produces raw_log-style data", () => {
202
238
  // Simulates what PiMonoSession.handleAgentEvent does
203
239
  const event = {
@@ -54,11 +54,12 @@ describe("Session templates — registration", () => {
54
54
  await ensureTemplatesRegistered();
55
55
  });
56
56
 
57
- test("all 13 system templates are registered", () => {
57
+ test("all 14 system templates are registered", () => {
58
58
  const systemTemplates = [
59
59
  "system.agent.role",
60
60
  "system.agent.register",
61
61
  "system.agent.lead",
62
+ "system.agent.slack",
62
63
  "system.agent.worker",
63
64
  "system.agent.worker.slack",
64
65
  "system.agent.filesystem",
@@ -89,10 +90,10 @@ describe("Session templates — registration", () => {
89
90
  }
90
91
  });
91
92
 
92
- test("total of 18 session/system templates registered", () => {
93
+ test("total of 19 session/system templates registered", () => {
93
94
  const all = getAllTemplateDefinitions();
94
95
  const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
95
- expect(sessionSystem.length).toBe(18);
96
+ expect(sessionSystem.length).toBe(19);
96
97
  });
97
98
  });
98
99
 
@@ -150,6 +151,15 @@ describe("Session templates — individual resolution", () => {
150
151
  const result = resolveTemplate("system.agent.lead", {});
151
152
  expect(result.text).toContain("CRITICAL: You are a coordinator");
152
153
  expect(result.text).toContain("coordinator");
154
+ expect(result.text).not.toContain("slack-reply");
155
+ });
156
+
157
+ test("system.agent.slack contains Slack tool guidance", () => {
158
+ const result = resolveTemplate("system.agent.slack", {});
159
+ expect(result.text).toContain("Slack Tools");
160
+ expect(result.text).toContain("slack-reply");
161
+ expect(result.text).toContain("slack-read");
162
+ expect(result.text).toContain("slack-list-channels");
153
163
  });
154
164
 
155
165
  test("system.agent.worker contains worker tools", () => {
@@ -0,0 +1,202 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createServer, type Server } from "node:http";
3
+ import {
4
+ buildContextPreamble,
5
+ CONTEXT_PREAMBLE_MAX_CHARS,
6
+ CONTEXT_PREAMBLE_MAX_TOKENS,
7
+ fetchTaskContextForPreamble,
8
+ type TaskContextForPreamble,
9
+ } from "../commands/context-preamble";
10
+
11
+ const TEST_PORT = 19091;
12
+ const API_URL = `http://localhost:${TEST_PORT}`;
13
+ const API_KEY = "test-key";
14
+
15
+ // In-memory task store for the mock server
16
+ const mockTasks: Record<string, TaskContextForPreamble> = {};
17
+
18
+ let server: Server;
19
+
20
+ beforeAll(async () => {
21
+ server = createServer((req, res) => {
22
+ const url = req.url ?? "";
23
+ const match = url.match(/^\/api\/tasks\/([^/?]+)/);
24
+ if (match) {
25
+ const id = match[1];
26
+ const task = mockTasks[id];
27
+ if (task) {
28
+ res.writeHead(200, { "Content-Type": "application/json" });
29
+ res.end(JSON.stringify(task));
30
+ } else {
31
+ res.writeHead(404, { "Content-Type": "application/json" });
32
+ res.end(JSON.stringify({ error: "not found" }));
33
+ }
34
+ return;
35
+ }
36
+ res.writeHead(404);
37
+ res.end();
38
+ });
39
+
40
+ await new Promise<void>((resolve) => server.listen(TEST_PORT, resolve));
41
+ });
42
+
43
+ afterAll(() => {
44
+ server.close();
45
+ // Clear mocks
46
+ for (const k of Object.keys(mockTasks)) delete mockTasks[k];
47
+ });
48
+
49
+ function seedTask(task: TaskContextForPreamble): void {
50
+ mockTasks[task.id] = task;
51
+ }
52
+
53
+ describe("fetchTaskContextForPreamble", () => {
54
+ test("returns null on 404", async () => {
55
+ const result = await fetchTaskContextForPreamble(API_URL, API_KEY, "missing-id");
56
+ expect(result).toBeNull();
57
+ });
58
+
59
+ test("fetches task context fields", async () => {
60
+ seedTask({
61
+ id: "task-a",
62
+ task: "Build the widget",
63
+ output: "Widget built successfully",
64
+ status: "completed",
65
+ attachments: [{ kind: "url", name: "Report", url: "https://example.com/report" }],
66
+ });
67
+
68
+ const result = await fetchTaskContextForPreamble(API_URL, API_KEY, "task-a");
69
+ expect(result).not.toBeNull();
70
+ expect(result?.id).toBe("task-a");
71
+ expect(result?.task).toBe("Build the widget");
72
+ expect(result?.output).toBe("Widget built successfully");
73
+ expect(result?.attachments).toHaveLength(1);
74
+ expect(result?.attachments?.[0].name).toBe("Report");
75
+ });
76
+ });
77
+
78
+ describe("buildContextPreamble", () => {
79
+ test("returns null when parent task not found", async () => {
80
+ const result = await buildContextPreamble(API_URL, API_KEY, "nonexistent-parent");
81
+ expect(result).toBeNull();
82
+ });
83
+
84
+ test("includes parent task subject and output in preamble", async () => {
85
+ seedTask({
86
+ id: "parent-1",
87
+ task: "Fix the auth bug in login flow",
88
+ output: "Fixed by patching jwt validation in auth.ts:42",
89
+ status: "completed",
90
+ });
91
+
92
+ const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-1");
93
+ expect(preamble).not.toBeNull();
94
+ expect(preamble).toContain("parent-1");
95
+ expect(preamble).toContain("Fix the auth bug in login flow");
96
+ expect(preamble).toContain("Fixed by patching jwt validation");
97
+ expect(preamble).toContain("get-task-details");
98
+ expect(preamble).toContain("Prior Conversation Context");
99
+ });
100
+
101
+ test("includes attachment pointers in preamble", async () => {
102
+ seedTask({
103
+ id: "parent-2",
104
+ task: "Generate a report",
105
+ output: "Report generated",
106
+ status: "completed",
107
+ attachments: [
108
+ { kind: "url", name: "Final Report", url: "https://example.com/report.pdf" },
109
+ {
110
+ kind: "agent-fs",
111
+ name: "Raw Data",
112
+ path: "thoughts/agent/research/data.md",
113
+ orgId: "org-123",
114
+ driveId: "drv-456",
115
+ },
116
+ ],
117
+ });
118
+
119
+ const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-2");
120
+ expect(preamble).toContain("Final Report");
121
+ expect(preamble).toContain("https://example.com/report.pdf");
122
+ expect(preamble).toContain("Raw Data");
123
+ expect(preamble).toContain("live.agent-fs.dev");
124
+ expect(preamble).toContain("org-123");
125
+ expect(preamble).toContain("drv-456");
126
+ });
127
+
128
+ test("shows 'no output recorded' when task has no output or progress", async () => {
129
+ seedTask({
130
+ id: "parent-no-output",
131
+ task: "A task with no output yet",
132
+ status: "in_progress",
133
+ });
134
+
135
+ const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-no-output");
136
+ expect(preamble).toContain("no output recorded");
137
+ });
138
+
139
+ test("walks ancestor chain and includes older ancestors as pointers", async () => {
140
+ seedTask({
141
+ id: "grandparent-1",
142
+ task: "Initial research task",
143
+ output: "Research complete",
144
+ status: "completed",
145
+ });
146
+ seedTask({
147
+ id: "child-of-grandparent",
148
+ task: "Second task referencing research",
149
+ output: "Second task done",
150
+ status: "completed",
151
+ parentTaskId: "grandparent-1",
152
+ });
153
+
154
+ const preamble = await buildContextPreamble(API_URL, API_KEY, "child-of-grandparent");
155
+ expect(preamble).not.toBeNull();
156
+ // Immediate parent (child-of-grandparent) gets inline detail
157
+ expect(preamble).toContain("child-of-grandparent");
158
+ expect(preamble).toContain("Second task done");
159
+ // Grandparent gets pointer-only entry
160
+ expect(preamble).toContain("grandparent-1");
161
+ expect(preamble).toContain("Older Ancestor Tasks");
162
+ expect(preamble).toContain("Initial research task");
163
+ });
164
+
165
+ test("enforces token budget — truncates oversized output", async () => {
166
+ // Generate output that exceeds the budget
167
+ const hugeOutput = "x".repeat(CONTEXT_PREAMBLE_MAX_CHARS + 5000);
168
+ seedTask({
169
+ id: "parent-big",
170
+ task: "Task with very large output",
171
+ output: hugeOutput,
172
+ status: "completed",
173
+ });
174
+
175
+ const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-big");
176
+ expect(preamble).not.toBeNull();
177
+ // Preamble must be within budget (some slack for the truncation suffix)
178
+ expect(preamble?.length ?? 0).toBeLessThanOrEqual(
179
+ CONTEXT_PREAMBLE_MAX_CHARS + 300, // 300 chars slack for the truncation message
180
+ );
181
+ });
182
+
183
+ test("preamble starts with context section and ends with separator", async () => {
184
+ seedTask({
185
+ id: "parent-structure",
186
+ task: "A well-structured task",
187
+ output: "Done",
188
+ status: "completed",
189
+ });
190
+
191
+ const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-structure");
192
+ expect(preamble).toContain("---");
193
+ expect(preamble).toContain("Prior Conversation Context");
194
+ // Should end with trailing separator
195
+ expect(preamble?.trimEnd()).toMatch(/---\s*$/);
196
+ });
197
+
198
+ test("CONTEXT_PREAMBLE_MAX_TOKENS is 2000 by default", () => {
199
+ expect(CONTEXT_PREAMBLE_MAX_TOKENS).toBe(2000);
200
+ expect(CONTEXT_PREAMBLE_MAX_CHARS).toBe(8000);
201
+ });
202
+ });
@@ -1,76 +1,70 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { createServer as createHttpServer, type Server } from "node:http";
3
2
  import {
4
3
  type ApiConfig,
5
4
  ensureTaskFinished,
6
5
  handleStructuredOutputFallback,
7
6
  } from "../commands/runner";
8
7
 
9
- const TEST_PORT = 13099;
10
-
11
8
  // Configurable mock responses per test
12
9
  let mockGetTask: Record<string, unknown> | null = null;
13
10
  let mockGetTaskStatus = 200;
14
11
  let lastFinishBody: Record<string, unknown> | null = null;
15
12
  let mockFinishResponse: Record<string, unknown> = { success: true };
13
+ let mockFetchError: Error | null = null;
14
+ let originalFetch: typeof fetch;
16
15
 
17
16
  function resetMocks() {
18
17
  mockGetTask = null;
19
18
  mockGetTaskStatus = 200;
20
19
  lastFinishBody = null;
21
20
  mockFinishResponse = { success: true };
21
+ mockFetchError = null;
22
22
  }
23
23
 
24
- let server: Server;
25
-
26
- function makeConfig(port = TEST_PORT): ApiConfig {
24
+ function makeConfig(): ApiConfig {
27
25
  return {
28
- apiUrl: `http://localhost:${port}`,
26
+ apiUrl: "http://runner-fallback.test",
29
27
  apiKey: "test-key",
30
28
  agentId: "test-agent-id",
31
29
  };
32
30
  }
33
31
 
34
- beforeAll(async () => {
35
- server = createHttpServer(async (req, res) => {
36
- const chunks: Buffer[] = [];
37
- for await (const chunk of req) {
38
- chunks.push(chunk);
39
- }
40
- const body = Buffer.concat(chunks).toString();
41
- const url = req.url || "";
32
+ beforeAll(() => {
33
+ originalFetch = globalThis.fetch;
34
+ globalThis.fetch = (async (input, init) => {
35
+ if (mockFetchError) throw mockFetchError;
42
36
 
43
- // GET /api/tasks/:id
44
- if (req.method === "GET" && /^\/api\/tasks\/[^/]+$/.test(url)) {
37
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
38
+ const parsedUrl = new URL(url);
39
+ const method = init?.method ?? "GET";
40
+
41
+ if (method === "GET" && /^\/api\/tasks\/[^/]+$/.test(parsedUrl.pathname)) {
45
42
  if (!mockGetTask) {
46
- res.writeHead(mockGetTaskStatus);
47
- res.end(JSON.stringify({ error: "Not found" }));
48
- return;
43
+ return new Response(JSON.stringify({ error: "Not found" }), {
44
+ status: mockGetTaskStatus,
45
+ });
49
46
  }
50
- res.writeHead(mockGetTaskStatus, { "Content-Type": "application/json" });
51
- res.end(JSON.stringify(mockGetTask));
52
- return;
47
+ return new Response(JSON.stringify(mockGetTask), {
48
+ status: mockGetTaskStatus,
49
+ headers: { "Content-Type": "application/json" },
50
+ });
53
51
  }
54
52
 
55
- // POST /api/tasks/:id/finish
56
- if (req.method === "POST" && /^\/api\/tasks\/[^/]+\/finish$/.test(url)) {
53
+ if (method === "POST" && /^\/api\/tasks\/[^/]+\/finish$/.test(parsedUrl.pathname)) {
54
+ const body = typeof init?.body === "string" ? init.body : "";
57
55
  lastFinishBody = body ? JSON.parse(body) : null;
58
- res.writeHead(200, { "Content-Type": "application/json" });
59
- res.end(JSON.stringify(mockFinishResponse));
60
- return;
56
+ return new Response(JSON.stringify(mockFinishResponse), {
57
+ status: 200,
58
+ headers: { "Content-Type": "application/json" },
59
+ });
61
60
  }
62
61
 
63
- res.writeHead(404);
64
- res.end("Not found");
65
- });
66
-
67
- await new Promise<void>((resolve) => {
68
- server.listen(TEST_PORT, () => resolve());
69
- });
62
+ return new Response("Not found", { status: 404 });
63
+ }) as typeof fetch;
70
64
  });
71
65
 
72
66
  afterAll(() => {
73
- server.close();
67
+ globalThis.fetch = originalFetch;
74
68
  });
75
69
 
76
70
  describe("handleStructuredOutputFallback", () => {
@@ -175,10 +169,9 @@ describe("handleStructuredOutputFallback", () => {
175
169
 
176
170
  test("returns fetch-error on network error", async () => {
177
171
  resetMocks();
178
- // Use a port that nothing listens on
179
- const badConfig = makeConfig(19999);
172
+ mockFetchError = new Error("network down");
180
173
 
181
- const result = await handleStructuredOutputFallback(badConfig, "task-7", "claude");
174
+ const result = await handleStructuredOutputFallback(makeConfig(), "task-7", "claude");
182
175
  expect(result.kind).toBe("fetch-error");
183
176
  expect((result as { kind: "fetch-error"; error: string }).error).toBeTruthy();
184
177
  });
@@ -227,6 +220,92 @@ describe("ensureTaskFinished", () => {
227
220
  expect(lastFinishBody!.output).toBe("Process completed successfully (no output captured)");
228
221
  });
229
222
 
223
+ test("uses provider output when no outputSchema exists", async () => {
224
+ resetMocks();
225
+ mockGetTask = {
226
+ id: "task-provider-output",
227
+ task: "Do work",
228
+ status: "in_progress",
229
+ output: null,
230
+ progress: null,
231
+ logs: [],
232
+ };
233
+
234
+ await ensureTaskFinished(
235
+ makeConfig(),
236
+ "worker",
237
+ "task-provider-output",
238
+ 0,
239
+ undefined,
240
+ "Provider final answer",
241
+ "pi",
242
+ );
243
+
244
+ expect(lastFinishBody).toBeTruthy();
245
+ expect(lastFinishBody!.status).toBe("completed");
246
+ expect(lastFinishBody!.output).toBe("Provider final answer");
247
+ });
248
+
249
+ test("accepts provider output that satisfies outputSchema", async () => {
250
+ resetMocks();
251
+ mockGetTask = {
252
+ id: "task-provider-schema-valid",
253
+ task: "Do work",
254
+ status: "in_progress",
255
+ output: null,
256
+ outputSchema: {
257
+ type: "object",
258
+ required: ["result"],
259
+ properties: { result: { type: "string" } },
260
+ },
261
+ logs: [],
262
+ };
263
+
264
+ await ensureTaskFinished(
265
+ makeConfig(),
266
+ "worker",
267
+ "task-provider-schema-valid",
268
+ 0,
269
+ undefined,
270
+ '{"result":"ok"}',
271
+ "pi",
272
+ );
273
+
274
+ expect(lastFinishBody).toBeTruthy();
275
+ expect(lastFinishBody!.status).toBe("completed");
276
+ expect(lastFinishBody!.output).toBe('{"result":"ok"}');
277
+ });
278
+
279
+ test("fails provider output that violates outputSchema", async () => {
280
+ resetMocks();
281
+ mockGetTask = {
282
+ id: "task-provider-schema-invalid",
283
+ task: "Do work",
284
+ status: "in_progress",
285
+ output: null,
286
+ outputSchema: {
287
+ type: "object",
288
+ required: ["result"],
289
+ properties: { result: { type: "string" } },
290
+ },
291
+ logs: [],
292
+ };
293
+
294
+ await ensureTaskFinished(
295
+ makeConfig(),
296
+ "worker",
297
+ "task-provider-schema-invalid",
298
+ 0,
299
+ undefined,
300
+ "plain text",
301
+ "pi",
302
+ );
303
+
304
+ expect(lastFinishBody).toBeTruthy();
305
+ expect(lastFinishBody!.status).toBe("failed");
306
+ expect(lastFinishBody!.failureReason).toContain("outputSchema");
307
+ });
308
+
230
309
  test("sets failed status for schema-fail fallback", async () => {
231
310
  resetMocks();
232
311
  mockGetTask = {
@@ -13,6 +13,7 @@ import {
13
13
  initDb,
14
14
  startTask,
15
15
  } from "../be/db";
16
+ import { createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
16
17
 
17
18
  const TEST_DB_PATH = "./test-task-completion-idempotency.sqlite";
18
19
 
@@ -318,3 +319,91 @@ describe("store-progress idempotency on terminal status (integration via DB laye
318
319
  expect(after!.output).toBe("manually written");
319
320
  });
320
321
  });
322
+
323
+ interface FollowUpRow {
324
+ id: string;
325
+ agentId: string | null;
326
+ parentTaskId: string | null;
327
+ taskType: string | null;
328
+ task: string;
329
+ slackChannelId: string | null;
330
+ slackThreadTs: string | null;
331
+ slackUserId: string | null;
332
+ }
333
+
334
+ function listFollowUpTasks(parentTaskId: string): FollowUpRow[] {
335
+ return getDb()
336
+ .prepare<FollowUpRow, [string]>(
337
+ `SELECT id, agentId, parentTaskId, taskType, task, slackChannelId, slackThreadTs, slackUserId
338
+ FROM agent_tasks
339
+ WHERE parentTaskId = ? AND taskType = 'follow-up'
340
+ ORDER BY createdAt ASC`,
341
+ )
342
+ .all(parentTaskId);
343
+ }
344
+
345
+ describe("worker task follow-up creation", () => {
346
+ test("creates lead follow-up for completed worker task", () => {
347
+ const lead = createAgent({
348
+ name: "follow-up-lead-1",
349
+ isLead: true,
350
+ status: "idle",
351
+ capabilities: [],
352
+ });
353
+ const worker = createAgent({
354
+ name: "follow-up-worker-1",
355
+ isLead: false,
356
+ status: "idle",
357
+ capabilities: [],
358
+ });
359
+ const task = createTaskExtended("Worker task", {
360
+ agentId: worker.id,
361
+ slackChannelId: "C123",
362
+ slackThreadTs: "1700000000.000001",
363
+ slackUserId: "U123",
364
+ });
365
+ startTask(task.id, worker.id);
366
+
367
+ const completed = completeTask(task.id, "Worker output");
368
+ expect(completed).not.toBeNull();
369
+
370
+ const followUp = createWorkerTaskFollowUp({
371
+ task: completed!,
372
+ status: "completed",
373
+ output: "Worker output",
374
+ });
375
+
376
+ expect(followUp).not.toBeNull();
377
+ const rows = listFollowUpTasks(task.id);
378
+ expect(rows).toHaveLength(1);
379
+ expect(rows[0]!.agentId).toBe(lead.id);
380
+ expect(rows[0]!.parentTaskId).toBe(task.id);
381
+ expect(rows[0]!.slackChannelId).toBe("C123");
382
+ expect(rows[0]!.slackThreadTs).toBe("1700000000.000001");
383
+ expect(rows[0]!.slackUserId).toBe("U123");
384
+ expect(rows[0]!.task).toContain("Worker output");
385
+ });
386
+
387
+ test("does not create follow-up for lead-owned task", () => {
388
+ const lead = createAgent({
389
+ name: "follow-up-lead-2",
390
+ isLead: true,
391
+ status: "idle",
392
+ capabilities: [],
393
+ });
394
+ const task = createTaskExtended("Lead task", { agentId: lead.id });
395
+ startTask(task.id, lead.id);
396
+
397
+ const completed = completeTask(task.id, "Lead output");
398
+ expect(completed).not.toBeNull();
399
+
400
+ const followUp = createWorkerTaskFollowUp({
401
+ task: completed!,
402
+ status: "completed",
403
+ output: "Lead output",
404
+ });
405
+
406
+ expect(followUp).toBeNull();
407
+ expect(listFollowUpTasks(task.id)).toHaveLength(0);
408
+ });
409
+ });
@@ -142,12 +142,20 @@ export async function cancelTaskHandler(
142
142
 
143
143
  if ("content" in result) return result;
144
144
 
145
+ const structuredContent = {
146
+ yourAgentId: agentId,
147
+ ...result,
148
+ };
149
+
145
150
  return {
146
- content: [{ type: "text", text: result.message }],
147
- structuredContent: {
148
- yourAgentId: agentId,
149
- ...result,
150
- },
151
+ content: [
152
+ { type: "text", text: result.message },
153
+ {
154
+ type: "text",
155
+ text: JSON.stringify(structuredContent),
156
+ },
157
+ ],
158
+ structuredContent,
151
159
  };
152
160
  }
153
161
 
@@ -65,17 +65,25 @@ export async function getTaskDetailsHandler(
65
65
  ? { name: requestedByUser.name, email: requestedByUser.email }
66
66
  : undefined;
67
67
 
68
+ const structuredContent = {
69
+ yourAgentId: agentId,
70
+ success: true,
71
+ message: `Task "${taskId}" details retrieved.`,
72
+ task,
73
+ requestedBy,
74
+ logs,
75
+ attachments,
76
+ };
77
+
68
78
  return {
69
- content: [{ type: "text", text: `Task "${taskId}" details retrieved.` }],
70
- structuredContent: {
71
- yourAgentId: agentId,
72
- success: true,
73
- message: `Task "${taskId}" details retrieved.`,
74
- task,
75
- requestedBy,
76
- logs,
77
- attachments,
78
- },
79
+ content: [
80
+ { type: "text", text: `Task "${taskId}" details retrieved.` },
81
+ {
82
+ type: "text",
83
+ text: JSON.stringify(structuredContent),
84
+ },
85
+ ],
86
+ structuredContent,
79
87
  };
80
88
  }
81
89