@desplega.ai/agent-swarm 1.82.0 → 1.83.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 (35) hide show
  1. package/openapi.json +43 -5
  2. package/package.json +6 -6
  3. package/src/be/db.ts +16 -3
  4. package/src/be/migrations/073_task_attachments_agent_fs_ids.sql +15 -0
  5. package/src/be/schedules/validate.ts +21 -0
  6. package/src/be/skill-sync.ts +65 -15
  7. package/src/commands/provider-credentials.ts +11 -0
  8. package/src/commands/runner.ts +41 -54
  9. package/src/http/schedules.ts +34 -9
  10. package/src/http/skills.ts +27 -2
  11. package/src/http/tasks.ts +7 -3
  12. package/src/providers/pi-mono-adapter.ts +20 -3
  13. package/src/providers/types.ts +5 -1
  14. package/src/slack/blocks.ts +132 -1
  15. package/src/slack/responses.ts +15 -5
  16. package/src/slack/watcher.ts +12 -0
  17. package/src/tests/credential-check.test.ts +47 -0
  18. package/src/tests/http-api-integration.test.ts +36 -0
  19. package/src/tests/rest-api.test.ts +51 -1
  20. package/src/tests/runner-skills-refresh.test.ts +200 -0
  21. package/src/tests/schedule-validation-helper.test.ts +51 -0
  22. package/src/tests/skill-sync.test.ts +73 -9
  23. package/src/tests/skills-signature.test.ts +141 -0
  24. package/src/tests/slack-attachments-block.test.ts +240 -0
  25. package/src/tests/slack-blocks.test.ts +162 -0
  26. package/src/tests/slack-watcher.test.ts +83 -0
  27. package/src/tests/store-progress-attachments-handler.test.ts +480 -0
  28. package/src/tests/store-progress-attachments.test.ts +41 -0
  29. package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
  30. package/src/tools/schedules/update-schedule.ts +48 -8
  31. package/src/tools/slack-upload-file.ts +17 -5
  32. package/src/tools/store-progress.ts +55 -19
  33. package/src/types.ts +21 -1
  34. package/src/utils/constants.ts +58 -0
  35. package/src/utils/skills-refresh.ts +123 -0
package/src/http/tasks.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  getLeadAgent,
11
11
  getLogsByTaskId,
12
12
  getPausedTasksForAgent,
13
+ getTaskAttachments,
13
14
  getTaskById,
14
15
  getTasksCount,
15
16
  getUserById,
@@ -140,7 +141,9 @@ const getTask = route({
140
141
  method: "get",
141
142
  path: "/api/tasks/{id}",
142
143
  pattern: ["api", "tasks", null],
143
- summary: "Get task details with logs",
144
+ summary: "Get task details with logs and attachments",
145
+ description:
146
+ "Returns the full `AgentTask` row decorated with `logs` (capped by `logsLimit`) and `attachments` (pointer-based artifacts stored on the task, ordered by `created_at`).",
144
147
  tags: ["Tasks"],
145
148
  params: z.object({ id: z.string() }),
146
149
  query: z.object({
@@ -148,7 +151,7 @@ const getTask = route({
148
151
  logsLimit: z.coerce.number().int().min(1).max(1000).optional(),
149
152
  }),
150
153
  responses: {
151
- 200: { description: "Task with logs" },
154
+ 200: { description: "Task with logs and attachments" },
152
155
  404: { description: "Task not found" },
153
156
  },
154
157
  });
@@ -523,7 +526,8 @@ export async function handleTasks(
523
526
  }
524
527
 
525
528
  const logs = getLogsByTaskId(parsed.params.id, parsed.query.logsLimit ?? 200);
526
- json(res, { ...task, logs });
529
+ const attachments = getTaskAttachments(parsed.params.id);
530
+ json(res, { ...task, logs, attachments });
527
531
  return true;
528
532
  }
529
533
 
@@ -72,17 +72,34 @@ function modelToCredKeys(modelStr: string | undefined): string[] | null {
72
72
 
73
73
  /**
74
74
  * Pi-mono is satisfied by ANY of:
75
- * 1. `~/.pi/agent/auth.json` exists.
76
- * 2. `MODEL_OVERRIDE` is set to a provider-prefixed model only the
75
+ * 1. `MODEL_OVERRIDE` selects the `amazon-bedrock` provider — credential
76
+ * resolution is delegated to the AWS SDK's default chain at first
77
+ * inference call. agent-swarm does no presence check; if creds are
78
+ * missing the SDK error surfaces in the session log.
79
+ * 2. `~/.pi/agent/auth.json` exists.
80
+ * 3. `MODEL_OVERRIDE` is set to a provider-prefixed model — only the
77
81
  * matching provider's key is required.
78
- * 3. `MODEL_OVERRIDE` is empty / unprefixed — any one of the supported
82
+ * 4. `MODEL_OVERRIDE` is empty / unprefixed — any one of the supported
79
83
  * keys (ANTHROPIC_API_KEY / OPENROUTER_API_KEY / OPENAI_API_KEY) is
80
84
  * enough.
85
+ *
86
+ * Bedrock is checked first so a stale `auth.json` (Anthropic / OpenRouter
87
+ * creds from a previous login) doesn't get falsely reported as the
88
+ * satisfying source when the model is actually going to AWS.
81
89
  */
82
90
  export function checkPiMonoCredentials(
83
91
  env: Record<string, string | undefined>,
84
92
  opts: CredCheckOptions = {},
85
93
  ): CredStatus {
94
+ if (env.MODEL_OVERRIDE?.toLowerCase().startsWith("amazon-bedrock/")) {
95
+ return {
96
+ ready: true,
97
+ missing: [],
98
+ satisfiedBy: "sdk-delegated",
99
+ hint: "AWS SDK will resolve credentials at first Bedrock call (env, ~/.aws/*, SSO, IMDS, etc.).",
100
+ };
101
+ }
102
+
86
103
  const homeDir = opts.homeDir ?? env.HOME ?? "/root";
87
104
  const probe = opts.fs?.existsSync ?? existsSync;
88
105
  const authFile = `${homeDir}/.pi/agent/auth.json`;
@@ -163,11 +163,15 @@ export interface ProviderAdapter {
163
163
  * (e.g. `codex login --with-api-key`) still needs to run before the
164
164
  * adapter can use them. Workers should treat this as "ready" for the
165
165
  * purposes of the boot loop — the side-effect is the entrypoint's job.
166
+ * - `'sdk-delegated'` — the harness's underlying SDK owns credential
167
+ * resolution at runtime (e.g. AWS SDK default chain for pi-mono +
168
+ * `MODEL_OVERRIDE=amazon-bedrock/...`); agent-swarm does no presence check
169
+ * and any error surfaces from the first inference call.
166
170
  */
167
171
  export interface CredStatus {
168
172
  ready: boolean;
169
173
  missing: string[];
170
- satisfiedBy?: "env" | "file" | "side-effect-pending";
174
+ satisfiedBy?: "env" | "file" | "side-effect-pending" | "sdk-delegated";
171
175
  hint?: string;
172
176
  }
173
177
 
@@ -5,7 +5,8 @@
5
5
  * across responses.ts, handlers.ts, thread-buffer.ts).
6
6
  */
7
7
 
8
- import { getAppUrl } from "../utils/constants";
8
+ import type { TaskAttachment } from "../types";
9
+ import { buildAgentFsLiveUrl, getAppUrl } from "../utils/constants";
9
10
 
10
11
  // Slack limits section text to 3000 chars; we use 2900 for safety
11
12
  const MAX_SECTION_LENGTH = 2900;
@@ -127,6 +128,62 @@ function cancelActionBlock(taskId: string): SlackBlock {
127
128
 
128
129
  // --- Utilities ---
129
130
 
131
+ // Mirrors the `store-progress` input cap so a misbehaving agent can't fill
132
+ // the Slack card with hundreds of lines.
133
+ const SLACK_ATTACHMENTS_MAX = 20;
134
+
135
+ /**
136
+ * Resolve an attachment to a Slack-friendly display string — a plain URL
137
+ * when one can be derived, otherwise a `<kind>:<pointer>` fallback. We use
138
+ * *plain* URLs (no `<URL|text>` mrkdwn shortcut) because Slack auto-unfurls
139
+ * them and the shortcut form has historically triggered `invalid_blocks`.
140
+ *
141
+ * `agent-fs` attachments emit a public live-host URL when the row carries
142
+ * `orgId` and `driveId` (or when the operator-set
143
+ * `AGENT_FS_DEFAULT_ORG_ID` / `AGENT_FS_DEFAULT_DRIVE_ID` env-vars provide
144
+ * a fallback). Without either, we keep the `agent-fs:<path>` raw fallback so
145
+ * the link is at least copy-pasteable.
146
+ */
147
+ function resolveAttachmentDisplay(a: TaskAttachment): string {
148
+ switch (a.kind) {
149
+ case "url":
150
+ return a.url ?? "";
151
+ case "page":
152
+ return a.pageId ? `${getAppUrl()}/pages/${a.pageId}` : "page:";
153
+ case "agent-fs": {
154
+ const url = buildAgentFsLiveUrl({
155
+ path: a.path,
156
+ orgId: a.orgId,
157
+ driveId: a.driveId,
158
+ });
159
+ return url ?? `agent-fs:${a.path ?? ""}`;
160
+ }
161
+ case "shared-fs":
162
+ return `shared-fs:${a.path ?? ""}`;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Build a compact "Attachments (N):" block in Slack mrkdwn for the completion
168
+ * card. Returns empty string when there are no attachments so callers can
169
+ * blindly concat without worrying about a stray label.
170
+ *
171
+ * Per-line format: `• <name> — _<intent>_ — <plain URL>` where the italic
172
+ * descriptor falls back to `description` and is omitted when both are empty.
173
+ */
174
+ export function formatAttachmentsBlockForSlack(attachments: TaskAttachment[]): string {
175
+ if (attachments.length === 0) return "";
176
+ const capped = attachments.slice(0, SLACK_ATTACHMENTS_MAX);
177
+ const lines = capped.map((a) => {
178
+ const descriptor = a.intent || a.description;
179
+ const middle = descriptor ? ` — _${descriptor}_` : "";
180
+ const display = resolveAttachmentDisplay(a);
181
+ const tail = display ? ` — ${display}` : "";
182
+ return `• *${a.name}*${middle}${tail}`;
183
+ });
184
+ return `\n\n*Attachments (${attachments.length}):*\n${lines.join("\n")}`;
185
+ }
186
+
130
187
  /**
131
188
  * Format a duration between two dates in a compact human-readable form.
132
189
  * Examples: "45s", "2m 14s", "1h 30m"
@@ -154,6 +211,14 @@ export interface TreeNode {
154
211
  slackReplySent?: boolean;
155
212
  output?: string; // Only used when !slackReplySent on completion
156
213
  failureReason?: string; // Always shown on failure
214
+ /**
215
+ * Pointer-based attachments to surface on the tree-message render. The
216
+ * watcher populates this for completed/terminal nodes so links survive on
217
+ * the tree path (`buildTreeBlocks`) — not just the DM completion card
218
+ * (`buildCompletedBlocks` / `responses.ts`). Optional so unit tests and
219
+ * non-attachment paths stay terse.
220
+ */
221
+ attachments?: TaskAttachment[];
157
222
  children: TreeNode[];
158
223
  }
159
224
 
@@ -169,6 +234,11 @@ export function buildCompletedBlocks(opts: {
169
234
  body: string;
170
235
  duration?: string;
171
236
  minimal?: boolean; // true = suppress body (agent already replied via slack-reply)
237
+ /**
238
+ * Optional trailer rendered even when `minimal` is true. Used for the
239
+ * attachments block so links survive on the compact completion card.
240
+ */
241
+ trailer?: string;
172
242
  }): SlackBlock[] {
173
243
  const taskLink = getTaskLink(opts.taskId);
174
244
  let line = `✅ *${opts.agentName}* (${taskLink})`;
@@ -181,6 +251,10 @@ export function buildCompletedBlocks(opts: {
181
251
  for (const chunk of splitText(opts.body)) {
182
252
  blocks.push(sectionBlock(chunk));
183
253
  }
254
+ } else if (opts.trailer && opts.trailer.length > 0) {
255
+ for (const chunk of splitText(opts.trailer)) {
256
+ blocks.push(sectionBlock(chunk));
257
+ }
184
258
  }
185
259
  return blocks;
186
260
  }
@@ -375,6 +449,34 @@ function isTreeActive(node: TreeNode): boolean {
375
449
  return node.children.some(isTreeActive);
376
450
  }
377
451
 
452
+ // Cap on completed-task attachment blocks rendered per tree-message. Slack's
453
+ // API enforces 50-block / 40KB ceilings; the existing tree section + cancel
454
+ // buttons already consume a few blocks, and each attachment block can carry
455
+ // up to SLACK_ATTACHMENTS_MAX (=20) lines. 10 keeps us well inside both
456
+ // limits even on wide trees while preserving the most-recent completions.
457
+ const SLACK_TREE_ATTACHMENT_BLOCKS_MAX = 10;
458
+
459
+ /**
460
+ * Flatten a tree (in render order: root first, then children) and collect
461
+ * every completed node whose `attachments` array is non-empty. The tree
462
+ * walks roots → children, mirroring `renderTree` so the attachment ordering
463
+ * matches what the reader sees in the main tree section.
464
+ */
465
+ function collectAttachmentNodes(roots: TreeNode[]): TreeNode[] {
466
+ const out: TreeNode[] = [];
467
+ for (const root of roots) {
468
+ const stack: TreeNode[] = [root];
469
+ while (stack.length > 0) {
470
+ const node = stack.shift() as TreeNode;
471
+ if (node.status === "completed" && node.attachments && node.attachments.length > 0) {
472
+ out.push(node);
473
+ }
474
+ for (const child of node.children) stack.push(child);
475
+ }
476
+ }
477
+ return out;
478
+ }
479
+
378
480
  /**
379
481
  * Build Slack blocks for a tree-based status message.
380
482
  *
@@ -383,6 +485,11 @@ function isTreeActive(node: TreeNode): boolean {
383
485
  *
384
486
  * For in-progress trees, includes a cancel button per active root.
385
487
  *
488
+ * Completed nodes that carry `attachments` (populated by the watcher from
489
+ * `task_attachments`) emit one extra section block per node listing the
490
+ * pointer-based artifacts. Capped at {@link SLACK_TREE_ATTACHMENT_BLOCKS_MAX}
491
+ * per tree-message; overflow becomes a `… and M more …` context footer.
492
+ *
386
493
  * @param roots - Array of root TreeNode objects (one per assigned task in a round)
387
494
  * @returns SlackBlock[] suitable for chat.postMessage / chat.update
388
495
  */
@@ -394,6 +501,30 @@ export function buildTreeBlocks(roots: TreeNode[]): SlackBlock[] {
394
501
  const treeTexts = roots.map(renderTree);
395
502
  const blocks: SlackBlock[] = [sectionBlock(treeTexts.join("\n\n"))];
396
503
 
504
+ // Attachment blocks for completed nodes, with per-tree-message cap.
505
+ const attachmentNodes = collectAttachmentNodes(roots);
506
+ const visibleAttachmentNodes = attachmentNodes.slice(0, SLACK_TREE_ATTACHMENT_BLOCKS_MAX);
507
+ for (const node of visibleAttachmentNodes) {
508
+ const body = formatAttachmentsBlockForSlack(node.attachments ?? []);
509
+ if (!body) continue;
510
+ // `formatAttachmentsBlockForSlack` returns a string starting with two
511
+ // newlines so it can be appended directly to a completion body. In tree
512
+ // mode we render the block on its own, prefixed by a header that ties
513
+ // the attachments back to the right child node.
514
+ const header = `*${node.agentName}* (${getTaskLink(node.taskId)})`;
515
+ blocks.push(sectionBlock(`${header}${body}`));
516
+ }
517
+ const hiddenAttachmentNodes = attachmentNodes.length - visibleAttachmentNodes.length;
518
+ if (hiddenAttachmentNodes > 0) {
519
+ blocks.push(
520
+ contextBlock(
521
+ `_… and ${hiddenAttachmentNodes} more completed task${
522
+ hiddenAttachmentNodes === 1 ? "" : "s"
523
+ } with attachments_`,
524
+ ),
525
+ );
526
+ }
527
+
397
528
  // Add cancel buttons for active roots
398
529
  for (const root of roots) {
399
530
  if (isTreeActive(root)) {
@@ -1,5 +1,5 @@
1
1
  import type { WebClient } from "@slack/web-api";
2
- import { getAgentById } from "../be/db";
2
+ import { getAgentById, getTaskAttachments } from "../be/db";
3
3
  import type { Agent, AgentTask } from "../types";
4
4
  import { getSlackApp } from "./app";
5
5
  import {
@@ -7,6 +7,7 @@ import {
7
7
  buildCompletedBlocks,
8
8
  buildFailedBlocks,
9
9
  buildProgressBlocks,
10
+ formatAttachmentsBlockForSlack,
10
11
  formatDuration,
11
12
  markdownToSlack,
12
13
  } from "./blocks";
@@ -50,6 +51,8 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
50
51
  if (task.status === "completed") {
51
52
  const output = task.output || "Task completed.";
52
53
  const slackOutput = markdownToSlack(output);
54
+ const attachmentsBlock = formatAttachmentsBlockForSlack(getTaskAttachments(task.id));
55
+ const body = slackOutput + attachmentsBlock;
53
56
  const duration =
54
57
  task.finishedAt && task.createdAt
55
58
  ? formatDuration(new Date(task.createdAt), new Date(task.finishedAt))
@@ -60,14 +63,18 @@ export async function sendTaskResponse(task: AgentTask): Promise<boolean> {
60
63
  const blocks = buildCompletedBlocks({
61
64
  agentName,
62
65
  taskId: task.id,
63
- body: slackOutput,
66
+ body,
64
67
  duration,
68
+ // When the agent already posted output via slack-reply, the header
69
+ // card stays minimal. We still surface the attachments block as a
70
+ // trailing addendum so links are visible without expanding the card.
65
71
  minimal: !!task.slackReplySent,
72
+ trailer: task.slackReplySent ? attachmentsBlock : undefined,
66
73
  });
67
74
  await sendWithPersona(client, {
68
75
  channel: task.slackChannelId,
69
76
  thread_ts: task.slackThreadTs,
70
- text: task.slackReplySent ? `✅ ${agentName} completed` : slackOutput,
77
+ text: task.slackReplySent ? `✅ ${agentName} completed` : body,
71
78
  username: getAgentDisplayName(agent),
72
79
  icon_emoji: getAgentEmoji(agent),
73
80
  blocks,
@@ -175,6 +182,8 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
175
182
  if (task.status === "completed") {
176
183
  const output = task.output || "Task completed.";
177
184
  const slackOutput = markdownToSlack(output);
185
+ const attachmentsBlock = formatAttachmentsBlockForSlack(getTaskAttachments(task.id));
186
+ const body = slackOutput + attachmentsBlock;
178
187
  const duration =
179
188
  task.finishedAt && task.createdAt
180
189
  ? formatDuration(new Date(task.createdAt), new Date(task.finishedAt))
@@ -185,11 +194,12 @@ export async function updateToFinal(task: AgentTask, messageTs: string): Promise
185
194
  blocks = buildCompletedBlocks({
186
195
  agentName,
187
196
  taskId: task.id,
188
- body: slackOutput,
197
+ body,
189
198
  duration,
190
199
  minimal: !!task.slackReplySent,
200
+ trailer: task.slackReplySent ? attachmentsBlock : undefined,
191
201
  });
192
- text = task.slackReplySent ? `✅ ${agentName} completed` : slackOutput;
202
+ text = task.slackReplySent ? `✅ ${agentName} completed` : body;
193
203
  } else if (task.status === "cancelled") {
194
204
  blocks = buildCancelledBlocks({ agentName, taskId: task.id });
195
205
  text = "Task cancelled";
@@ -3,6 +3,7 @@ import {
3
3
  getChildTasks,
4
4
  getCompletedSlackTasks,
5
5
  getInProgressSlackTasks,
6
+ getTaskAttachments,
6
7
  getTaskById,
7
8
  } from "../be/db";
8
9
  import type { AgentTask } from "../types";
@@ -133,6 +134,13 @@ export function buildTreeNodes(tree: TreeMessageState): TreeNode[] {
133
134
  childDuration = formatDuration(new Date(child.createdAt), new Date(child.finishedAt));
134
135
  }
135
136
 
137
+ // Only completed nodes get attachments — that's the only path
138
+ // `buildTreeBlocks` renders them, and the lookup is one query per
139
+ // completed task per tree render. Skipping non-completed avoids
140
+ // hot-path queries for every in-progress poll tick.
141
+ const childAttachments =
142
+ child.status === "completed" ? getTaskAttachments(child.id) : undefined;
143
+
136
144
  childNodes.push({
137
145
  taskId: child.id,
138
146
  agentName: childAgentName,
@@ -142,6 +150,7 @@ export function buildTreeNodes(tree: TreeMessageState): TreeNode[] {
142
150
  slackReplySent: child.slackReplySent,
143
151
  output: child.output ?? undefined,
144
152
  failureReason: child.failureReason ?? undefined,
153
+ attachments: childAttachments,
145
154
  children: [],
146
155
  });
147
156
 
@@ -150,6 +159,8 @@ export function buildTreeNodes(tree: TreeMessageState): TreeNode[] {
150
159
  }
151
160
  }
152
161
 
162
+ const rootAttachments = task.status === "completed" ? getTaskAttachments(task.id) : undefined;
163
+
153
164
  nodes.push({
154
165
  taskId: task.id,
155
166
  agentName,
@@ -159,6 +170,7 @@ export function buildTreeNodes(tree: TreeMessageState): TreeNode[] {
159
170
  slackReplySent: task.slackReplySent,
160
171
  output: task.output ?? undefined,
161
172
  failureReason: task.failureReason ?? undefined,
173
+ attachments: rootAttachments,
162
174
  children: childNodes,
163
175
  });
164
176
  }
@@ -224,6 +224,53 @@ describe("checkPiMonoCredentials", () => {
224
224
  ).toBe(true);
225
225
  }
226
226
  });
227
+
228
+ // ─── amazon-bedrock: AWS SDK delegates credential resolution ───────────────
229
+ // When MODEL_OVERRIDE selects amazon-bedrock, pi-mono routes through the AWS
230
+ // SDK's default credential chain (env, ~/.aws/*, SSO, IMDS, assume-role,
231
+ // web-identity, …). agent-swarm does no presence check beyond detecting the
232
+ // `amazon-bedrock/` prefix — the SDK validates at first inference call.
233
+ // Mirrors the codex auth.json "presence-only" pattern.
234
+
235
+ test("amazon-bedrock: ready (sdk-delegated) with no env vars and no auth.json", () => {
236
+ const env = { MODEL_OVERRIDE: "amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0" };
237
+ const status = checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
238
+ expect(status.ready).toBe(true);
239
+ expect(status.satisfiedBy).toBe("sdk-delegated");
240
+ expect(status.missing).toEqual([]);
241
+ });
242
+
243
+ test("amazon-bedrock: stays sdk-delegated even when ANTHROPIC_API_KEY is also set", () => {
244
+ // The Anthropic-shape key is irrelevant here — the model is routed through
245
+ // AWS Bedrock, not Anthropic. Reporting satisfiedBy="env" would mislead.
246
+ const env = {
247
+ MODEL_OVERRIDE: "amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0",
248
+ ANTHROPIC_API_KEY: "x",
249
+ };
250
+ const status = checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
251
+ expect(status.ready).toBe(true);
252
+ expect(status.satisfiedBy).toBe("sdk-delegated");
253
+ });
254
+
255
+ test("amazon-bedrock: stays sdk-delegated even when auth.json exists", () => {
256
+ // auth.json holds Anthropic/OpenRouter/OpenAI creds — none used by Bedrock.
257
+ // Bedrock branch must win over the file probe.
258
+ const env = { MODEL_OVERRIDE: "amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0" };
259
+ const status = checkPiMonoCredentials(env, {
260
+ homeDir: HOME,
261
+ fs: fsWith(new Set([AUTH])),
262
+ });
263
+ expect(status.ready).toBe(true);
264
+ expect(status.satisfiedBy).toBe("sdk-delegated");
265
+ });
266
+
267
+ test("amazon-bedrock: provider-prefix match is case-insensitive", () => {
268
+ // Mirrors modelToCredKeys' .toLowerCase() at line 54 of pi-mono-adapter.
269
+ const env = { MODEL_OVERRIDE: "Amazon-Bedrock/anthropic.claude-sonnet-4-20250514-v1:0" };
270
+ const status = checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
271
+ expect(status.ready).toBe(true);
272
+ expect(status.satisfiedBy).toBe("sdk-delegated");
273
+ });
227
274
  });
228
275
 
229
276
  // ─── opencode ────────────────────────────────────────────────────────────────
@@ -968,6 +968,42 @@ describe("Schedule CRUD", () => {
968
968
  expect(status).toBe(404);
969
969
  });
970
970
 
971
+ test("PUT /api/schedules/:id — cron-based schedule accepts null intervalMs", async () => {
972
+ // Reproduces CAI-1283: dashboard sends null for unused timing field
973
+ const { status, body } = await put(`/api/schedules/${scheduleId}`, {
974
+ body: { cronExpression: "0 2 * * *", intervalMs: null },
975
+ });
976
+ expect(status).toBe(200);
977
+ expect(body.cronExpression).toBe("0 2 * * *");
978
+ // intervalMs omitted from response when not set (null serialized as undefined in JSON)
979
+ expect(body.intervalMs == null).toBe(true);
980
+ });
981
+
982
+ test("PUT /api/schedules/:id — interval-based schedule accepts null cronExpression", async () => {
983
+ // Create an interval-based schedule to test against
984
+ const { body: created } = await post("/api/schedules", {
985
+ body: {
986
+ name: "interval-test-cai-1283",
987
+ taskTemplate: "interval task",
988
+ intervalMs: 60000,
989
+ },
990
+ });
991
+ const { status, body } = await put(`/api/schedules/${created.id}`, {
992
+ body: { intervalMs: 60000, cronExpression: null },
993
+ });
994
+ expect(status).toBe(200);
995
+ expect(body.intervalMs).toBe(60000);
996
+ // Clean up
997
+ await del(`/api/schedules/${created.id}`);
998
+ });
999
+
1000
+ test("PUT /api/schedules/:id — both intervalMs and cronExpression null returns 400", async () => {
1001
+ const { status } = await put(`/api/schedules/${scheduleId}`, {
1002
+ body: { cronExpression: null, intervalMs: null },
1003
+ });
1004
+ expect(status).toBe(400);
1005
+ });
1006
+
971
1007
  test("POST /api/schedules/:id/run — run now creates a task", async () => {
972
1008
  const { status, body } = await post(`/api/schedules/${scheduleId}/run`);
973
1009
  expect(status).toBe(200);
@@ -7,7 +7,9 @@ import {
7
7
  createTaskExtended,
8
8
  getAgentById,
9
9
  getDb,
10
+ getTaskAttachments,
10
11
  initDb,
12
+ insertTaskAttachment,
11
13
  updateAgentStatus,
12
14
  } from "../be/db";
13
15
 
@@ -137,7 +139,11 @@ async function handleRequest(
137
139
  return { status: 404, body: { error: "Task not found" } };
138
140
  }
139
141
 
140
- return { status: 200, body: task };
142
+ // Mirror the real `GET /api/tasks/:id` handler in `src/http/tasks.ts`
143
+ // by decorating the row with `attachments`. The mock omits `logs` since
144
+ // those tests live elsewhere; attachments are cheap enough to inline.
145
+ const attachments = getTaskAttachments(taskId);
146
+ return { status: 200, body: { ...(task as object), attachments } };
141
147
  }
142
148
 
143
149
  // GET /api/stats - Dashboard summary stats
@@ -538,6 +544,50 @@ describe("REST API Endpoints", () => {
538
544
  expect(data.task).toBe("Test task for GET endpoint");
539
545
  expect(data.status).toBe("unassigned");
540
546
  });
547
+
548
+ test("should include attachments[] in the response", async () => {
549
+ const task = createTaskExtended("Task with attachments", {
550
+ creatorAgentId: "test-agent-attach",
551
+ });
552
+ insertTaskAttachment({
553
+ taskId: task.id,
554
+ kind: "url",
555
+ name: "report",
556
+ url: "https://example.com/r.pdf",
557
+ intent: "primary deliverable",
558
+ isPrimary: true,
559
+ });
560
+ insertTaskAttachment({
561
+ taskId: task.id,
562
+ kind: "agent-fs",
563
+ name: "doc",
564
+ path: "/thoughts/a.md",
565
+ });
566
+
567
+ const response = await fetch(`${baseUrl}/api/tasks/${task.id}`);
568
+ expect(response.status).toBe(200);
569
+ const data = (await response.json()) as {
570
+ id: string;
571
+ attachments: Array<{ kind: string; name: string; url?: string; path?: string }>;
572
+ };
573
+ expect(data.attachments).toBeDefined();
574
+ expect(data.attachments.length).toBe(2);
575
+ expect(data.attachments[0]?.kind).toBe("url");
576
+ expect(data.attachments[0]?.name).toBe("report");
577
+ expect(data.attachments[1]?.kind).toBe("agent-fs");
578
+ expect(data.attachments[1]?.path).toBe("/thoughts/a.md");
579
+ });
580
+
581
+ test("should return an empty attachments[] when none are attached", async () => {
582
+ const task = createTaskExtended("Task without attachments", {
583
+ creatorAgentId: "test-agent-noattach",
584
+ });
585
+ const response = await fetch(`${baseUrl}/api/tasks/${task.id}`);
586
+ expect(response.status).toBe(200);
587
+ const data = (await response.json()) as { attachments: unknown[] };
588
+ expect(Array.isArray(data.attachments)).toBe(true);
589
+ expect(data.attachments.length).toBe(0);
590
+ });
541
591
  });
542
592
 
543
593
  describe("GET /api/stats", () => {