@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.
- package/openapi.json +43 -5
- package/package.json +6 -6
- package/src/be/db.ts +16 -3
- package/src/be/migrations/073_task_attachments_agent_fs_ids.sql +15 -0
- package/src/be/schedules/validate.ts +21 -0
- package/src/be/skill-sync.ts +65 -15
- package/src/commands/provider-credentials.ts +11 -0
- package/src/commands/runner.ts +41 -54
- package/src/http/schedules.ts +34 -9
- package/src/http/skills.ts +27 -2
- package/src/http/tasks.ts +7 -3
- package/src/providers/pi-mono-adapter.ts +20 -3
- package/src/providers/types.ts +5 -1
- package/src/slack/blocks.ts +132 -1
- package/src/slack/responses.ts +15 -5
- package/src/slack/watcher.ts +12 -0
- package/src/tests/credential-check.test.ts +47 -0
- package/src/tests/http-api-integration.test.ts +36 -0
- package/src/tests/rest-api.test.ts +51 -1
- package/src/tests/runner-skills-refresh.test.ts +200 -0
- package/src/tests/schedule-validation-helper.test.ts +51 -0
- package/src/tests/skill-sync.test.ts +73 -9
- package/src/tests/skills-signature.test.ts +141 -0
- package/src/tests/slack-attachments-block.test.ts +240 -0
- package/src/tests/slack-blocks.test.ts +162 -0
- package/src/tests/slack-watcher.test.ts +83 -0
- package/src/tests/store-progress-attachments-handler.test.ts +480 -0
- package/src/tests/store-progress-attachments.test.ts +41 -0
- package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
- package/src/tools/schedules/update-schedule.ts +48 -8
- package/src/tools/slack-upload-file.ts +17 -5
- package/src/tools/store-progress.ts +55 -19
- package/src/types.ts +21 -1
- package/src/utils/constants.ts +58 -0
- 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
|
-
|
|
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.
|
|
76
|
-
*
|
|
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
|
-
*
|
|
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`;
|
package/src/providers/types.ts
CHANGED
|
@@ -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
|
|
package/src/slack/blocks.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* across responses.ts, handlers.ts, thread-buffer.ts).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
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)) {
|
package/src/slack/responses.ts
CHANGED
|
@@ -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
|
|
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` :
|
|
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
|
|
197
|
+
body,
|
|
189
198
|
duration,
|
|
190
199
|
minimal: !!task.slackReplySent,
|
|
200
|
+
trailer: task.slackReplySent ? attachmentsBlock : undefined,
|
|
191
201
|
});
|
|
192
|
-
text = task.slackReplySent ? `✅ ${agentName} completed` :
|
|
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";
|
package/src/slack/watcher.ts
CHANGED
|
@@ -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
|
-
|
|
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", () => {
|