@desplega.ai/agent-swarm 1.82.0 → 1.83.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.
- package/openapi.json +5 -3
- package/package.json +1 -1
- package/src/be/db.ts +14 -1
- package/src/be/migrations/073_task_attachments_agent_fs_ids.sql +15 -0
- package/src/commands/provider-credentials.ts +11 -0
- 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/rest-api.test.ts +51 -1
- 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/tools/store-progress.ts +55 -19
- package/src/types.ts +21 -1
- package/src/utils/constants.ts +58 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildCompletedBlocks, formatAttachmentsBlockForSlack } from "../slack/blocks";
|
|
3
|
+
import type { TaskAttachment } from "../types";
|
|
4
|
+
|
|
5
|
+
// Slack block types are open unions — the builder returns `any`; we read it
|
|
6
|
+
// as `any` in the test to inspect the runtime shape.
|
|
7
|
+
// biome-ignore lint/suspicious/noExplicitAny: see comment above
|
|
8
|
+
type SlackBlock = any;
|
|
9
|
+
|
|
10
|
+
function mkAttachment(overrides: Partial<TaskAttachment>): TaskAttachment {
|
|
11
|
+
return {
|
|
12
|
+
id: crypto.randomUUID(),
|
|
13
|
+
taskId: "00000000-0000-0000-0000-000000000000",
|
|
14
|
+
agentId: null,
|
|
15
|
+
name: overrides.name ?? "attachment.txt",
|
|
16
|
+
kind: overrides.kind ?? "url",
|
|
17
|
+
isPrimary: overrides.isPrimary ?? false,
|
|
18
|
+
createdAt: "2026-05-22T00:00:00.000Z",
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sectionTexts(blocks: SlackBlock[]): string[] {
|
|
24
|
+
return blocks
|
|
25
|
+
.filter((b: SlackBlock) => b.type === "section")
|
|
26
|
+
.map((b: SlackBlock) => b.text?.text ?? "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("formatAttachmentsBlockForSlack", () => {
|
|
30
|
+
test("returns empty string when there are no attachments", () => {
|
|
31
|
+
expect(formatAttachmentsBlockForSlack([])).toBe("");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("renders url kind as a plain URL (no mrkdwn link shortcut)", () => {
|
|
35
|
+
const out = formatAttachmentsBlockForSlack([
|
|
36
|
+
mkAttachment({ kind: "url", name: "report", url: "https://example.com/r.pdf" }),
|
|
37
|
+
]);
|
|
38
|
+
expect(out).toContain("• *report*");
|
|
39
|
+
expect(out).toContain("https://example.com/r.pdf");
|
|
40
|
+
// Crucially: NOT the mrkdwn `<url|label>` shortcut — that triggers
|
|
41
|
+
// `invalid_blocks` in some Slack configurations and the spec mandates
|
|
42
|
+
// plain URLs so Slack auto-unfurls them.
|
|
43
|
+
expect(out).not.toContain("<https://example.com");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("uses intent in italics; falls back to description; omits both when absent", () => {
|
|
47
|
+
const intent = mkAttachment({ kind: "url", url: "u1", intent: "primary deliverable" });
|
|
48
|
+
const desc = mkAttachment({ kind: "url", url: "u2", description: "supporting" });
|
|
49
|
+
const neither = mkAttachment({ kind: "url", url: "u3" });
|
|
50
|
+
const out = formatAttachmentsBlockForSlack([intent, desc, neither]);
|
|
51
|
+
expect(out).toContain("_primary deliverable_");
|
|
52
|
+
expect(out).toContain("_supporting_");
|
|
53
|
+
// Neither — only one " — " separator between name and url.
|
|
54
|
+
const neitherLine = out.split("\n").find((l) => l.includes("u3") && l.startsWith("•"));
|
|
55
|
+
expect(neitherLine).toBeDefined();
|
|
56
|
+
expect(neitherLine).not.toContain("__");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("page kind resolves via APP_URL", () => {
|
|
60
|
+
const origAppUrl = process.env.APP_URL;
|
|
61
|
+
process.env.APP_URL = "https://app.example.test";
|
|
62
|
+
try {
|
|
63
|
+
const out = formatAttachmentsBlockForSlack([
|
|
64
|
+
mkAttachment({ kind: "page", pageId: "abc123" }),
|
|
65
|
+
]);
|
|
66
|
+
expect(out).toContain("https://app.example.test/pages/abc123");
|
|
67
|
+
} finally {
|
|
68
|
+
if (origAppUrl === undefined) {
|
|
69
|
+
delete process.env.APP_URL;
|
|
70
|
+
} else {
|
|
71
|
+
process.env.APP_URL = origAppUrl;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("agent-fs falls back to raw path display when org/drive ids are missing", () => {
|
|
77
|
+
const origOrg = process.env.AGENT_FS_DEFAULT_ORG_ID;
|
|
78
|
+
const origDrive = process.env.AGENT_FS_DEFAULT_DRIVE_ID;
|
|
79
|
+
delete process.env.AGENT_FS_DEFAULT_ORG_ID;
|
|
80
|
+
delete process.env.AGENT_FS_DEFAULT_DRIVE_ID;
|
|
81
|
+
try {
|
|
82
|
+
const out = formatAttachmentsBlockForSlack([
|
|
83
|
+
mkAttachment({ kind: "agent-fs", name: "doc", path: "/thoughts/a.md" }),
|
|
84
|
+
]);
|
|
85
|
+
expect(out).toContain("agent-fs:/thoughts/a.md");
|
|
86
|
+
expect(out).not.toContain("live.agent-fs.dev");
|
|
87
|
+
} finally {
|
|
88
|
+
if (origOrg === undefined) delete process.env.AGENT_FS_DEFAULT_ORG_ID;
|
|
89
|
+
else process.env.AGENT_FS_DEFAULT_ORG_ID = origOrg;
|
|
90
|
+
if (origDrive === undefined) delete process.env.AGENT_FS_DEFAULT_DRIVE_ID;
|
|
91
|
+
else process.env.AGENT_FS_DEFAULT_DRIVE_ID = origDrive;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("agent-fs with row-level orgId + driveId emits the live-host URL", () => {
|
|
96
|
+
const origHost = process.env.AGENT_FS_LIVE_URL;
|
|
97
|
+
delete process.env.AGENT_FS_LIVE_URL;
|
|
98
|
+
try {
|
|
99
|
+
const out = formatAttachmentsBlockForSlack([
|
|
100
|
+
mkAttachment({
|
|
101
|
+
kind: "agent-fs",
|
|
102
|
+
name: "doc",
|
|
103
|
+
path: "/thoughts/a.md",
|
|
104
|
+
orgId: "org-1",
|
|
105
|
+
driveId: "drive-1",
|
|
106
|
+
}),
|
|
107
|
+
]);
|
|
108
|
+
// Live URL strips the leading slash from `path` so the join is clean.
|
|
109
|
+
expect(out).toContain("https://live.agent-fs.dev/file/~/org-1/drive-1/thoughts/a.md");
|
|
110
|
+
expect(out).not.toContain("agent-fs:/thoughts/a.md");
|
|
111
|
+
} finally {
|
|
112
|
+
if (origHost === undefined) delete process.env.AGENT_FS_LIVE_URL;
|
|
113
|
+
else process.env.AGENT_FS_LIVE_URL = origHost;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("agent-fs uses AGENT_FS_DEFAULT_* env-var fallback when row has no ids", () => {
|
|
118
|
+
const origOrg = process.env.AGENT_FS_DEFAULT_ORG_ID;
|
|
119
|
+
const origDrive = process.env.AGENT_FS_DEFAULT_DRIVE_ID;
|
|
120
|
+
process.env.AGENT_FS_DEFAULT_ORG_ID = "fallback-org";
|
|
121
|
+
process.env.AGENT_FS_DEFAULT_DRIVE_ID = "fallback-drive";
|
|
122
|
+
try {
|
|
123
|
+
const out = formatAttachmentsBlockForSlack([
|
|
124
|
+
mkAttachment({ kind: "agent-fs", name: "doc", path: "thoughts/a.md" }),
|
|
125
|
+
]);
|
|
126
|
+
expect(out).toContain(
|
|
127
|
+
"https://live.agent-fs.dev/file/~/fallback-org/fallback-drive/thoughts/a.md",
|
|
128
|
+
);
|
|
129
|
+
} finally {
|
|
130
|
+
if (origOrg === undefined) delete process.env.AGENT_FS_DEFAULT_ORG_ID;
|
|
131
|
+
else process.env.AGENT_FS_DEFAULT_ORG_ID = origOrg;
|
|
132
|
+
if (origDrive === undefined) delete process.env.AGENT_FS_DEFAULT_DRIVE_ID;
|
|
133
|
+
else process.env.AGENT_FS_DEFAULT_DRIVE_ID = origDrive;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("agent-fs row-level org/drive ids win over env-var fallbacks", () => {
|
|
138
|
+
const origOrg = process.env.AGENT_FS_DEFAULT_ORG_ID;
|
|
139
|
+
const origDrive = process.env.AGENT_FS_DEFAULT_DRIVE_ID;
|
|
140
|
+
process.env.AGENT_FS_DEFAULT_ORG_ID = "fallback-org";
|
|
141
|
+
process.env.AGENT_FS_DEFAULT_DRIVE_ID = "fallback-drive";
|
|
142
|
+
try {
|
|
143
|
+
const out = formatAttachmentsBlockForSlack([
|
|
144
|
+
mkAttachment({
|
|
145
|
+
kind: "agent-fs",
|
|
146
|
+
name: "doc",
|
|
147
|
+
path: "thoughts/a.md",
|
|
148
|
+
orgId: "row-org",
|
|
149
|
+
driveId: "row-drive",
|
|
150
|
+
}),
|
|
151
|
+
]);
|
|
152
|
+
expect(out).toContain("https://live.agent-fs.dev/file/~/row-org/row-drive/thoughts/a.md");
|
|
153
|
+
expect(out).not.toContain("fallback-org");
|
|
154
|
+
expect(out).not.toContain("fallback-drive");
|
|
155
|
+
} finally {
|
|
156
|
+
if (origOrg === undefined) delete process.env.AGENT_FS_DEFAULT_ORG_ID;
|
|
157
|
+
else process.env.AGENT_FS_DEFAULT_ORG_ID = origOrg;
|
|
158
|
+
if (origDrive === undefined) delete process.env.AGENT_FS_DEFAULT_DRIVE_ID;
|
|
159
|
+
else process.env.AGENT_FS_DEFAULT_DRIVE_ID = origDrive;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("shared-fs displays the path verbatim", () => {
|
|
164
|
+
const out = formatAttachmentsBlockForSlack([
|
|
165
|
+
mkAttachment({ kind: "shared-fs", name: "log", path: "/var/log/x.log" }),
|
|
166
|
+
]);
|
|
167
|
+
expect(out).toContain("shared-fs:/var/log/x.log");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("caps at 20 lines but still prints the true count in the header", () => {
|
|
171
|
+
const attachments = Array.from({ length: 25 }, (_, i) =>
|
|
172
|
+
mkAttachment({ kind: "url", url: `https://x.test/${i}`, name: `n${i}` }),
|
|
173
|
+
);
|
|
174
|
+
const out = formatAttachmentsBlockForSlack(attachments);
|
|
175
|
+
// Header reports the real count.
|
|
176
|
+
expect(out).toContain("Attachments (25)");
|
|
177
|
+
// Body capped to 20 bullets.
|
|
178
|
+
const bulletCount = (out.match(/\n• /g) ?? []).length;
|
|
179
|
+
expect(bulletCount).toBe(20);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("starts with two newlines so it concatenates cleanly with the output body", () => {
|
|
183
|
+
const out = formatAttachmentsBlockForSlack([
|
|
184
|
+
mkAttachment({ kind: "url", url: "https://x.test", name: "x" }),
|
|
185
|
+
]);
|
|
186
|
+
expect(out.startsWith("\n\n")).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("buildCompletedBlocks trailer", () => {
|
|
191
|
+
const baseOpts = { agentName: "tester", taskId: crypto.randomUUID(), body: "Done." };
|
|
192
|
+
|
|
193
|
+
test("includes the body block when minimal=false", () => {
|
|
194
|
+
const blocks = buildCompletedBlocks(baseOpts);
|
|
195
|
+
const texts = sectionTexts(blocks);
|
|
196
|
+
expect(texts.some((t) => t.includes("Done."))).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("suppresses body when minimal=true and no trailer", () => {
|
|
200
|
+
const blocks = buildCompletedBlocks({ ...baseOpts, minimal: true });
|
|
201
|
+
const texts = sectionTexts(blocks);
|
|
202
|
+
expect(texts.some((t) => t.includes("Done."))).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("renders the trailer (attachments) even when minimal=true", () => {
|
|
206
|
+
const blocks = buildCompletedBlocks({
|
|
207
|
+
...baseOpts,
|
|
208
|
+
minimal: true,
|
|
209
|
+
trailer: "\n\n*Attachments (1):*\n• *report* — https://x.test",
|
|
210
|
+
});
|
|
211
|
+
const texts = sectionTexts(blocks);
|
|
212
|
+
// Header line is always present.
|
|
213
|
+
expect(texts.some((t) => t.includes("tester"))).toBe(true);
|
|
214
|
+
// Trailer rendered — body still suppressed.
|
|
215
|
+
expect(texts.some((t) => t.includes("Attachments (1)"))).toBe(true);
|
|
216
|
+
expect(texts.some((t) => t.includes("Done."))).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("Slack attachments block — env hygiene", () => {
|
|
221
|
+
let originalAppUrl: string | undefined;
|
|
222
|
+
beforeEach(() => {
|
|
223
|
+
originalAppUrl = process.env.APP_URL;
|
|
224
|
+
});
|
|
225
|
+
afterEach(() => {
|
|
226
|
+
if (originalAppUrl === undefined) {
|
|
227
|
+
delete process.env.APP_URL;
|
|
228
|
+
} else {
|
|
229
|
+
process.env.APP_URL = originalAppUrl;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("page-link falls back to DEFAULT_APP_URL when APP_URL is unset", () => {
|
|
234
|
+
delete process.env.APP_URL;
|
|
235
|
+
const out = formatAttachmentsBlockForSlack([mkAttachment({ kind: "page", pageId: "p42" })]);
|
|
236
|
+
// DEFAULT_APP_URL is https://app.agent-swarm.dev — the production fallback
|
|
237
|
+
// so links rendered out of the box still work.
|
|
238
|
+
expect(out).toContain("https://app.agent-swarm.dev/pages/p42");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
markdownToSlack,
|
|
14
14
|
type TreeNode,
|
|
15
15
|
} from "../slack/blocks";
|
|
16
|
+
import type { TaskAttachment } from "../types";
|
|
16
17
|
|
|
17
18
|
describe("markdownToSlack", () => {
|
|
18
19
|
test("converts bold correctly without italic interference", () => {
|
|
@@ -909,3 +910,164 @@ describe("buildTreeBlocks", () => {
|
|
|
909
910
|
expect(text).not.toContain("Should not appear");
|
|
910
911
|
});
|
|
911
912
|
});
|
|
913
|
+
|
|
914
|
+
// --- buildTreeBlocks attachment rendering (Phase 2a follow-up) ---
|
|
915
|
+
|
|
916
|
+
function mkAttachment(overrides: Partial<TaskAttachment>): TaskAttachment {
|
|
917
|
+
return {
|
|
918
|
+
id: crypto.randomUUID(),
|
|
919
|
+
taskId: "00000000-0000-0000-0000-000000000000",
|
|
920
|
+
agentId: null,
|
|
921
|
+
name: overrides.name ?? "attachment.txt",
|
|
922
|
+
kind: overrides.kind ?? "url",
|
|
923
|
+
isPrimary: overrides.isPrimary ?? false,
|
|
924
|
+
createdAt: "2026-05-22T00:00:00.000Z",
|
|
925
|
+
...overrides,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// biome-ignore lint/suspicious/noExplicitAny: section blocks are loose plain objects
|
|
930
|
+
type AnyBlock = any;
|
|
931
|
+
|
|
932
|
+
function sectionTexts(blocks: AnyBlock[]): string[] {
|
|
933
|
+
return blocks
|
|
934
|
+
.filter((b: AnyBlock) => b.type === "section")
|
|
935
|
+
.map((b: AnyBlock) => b.text?.text ?? "");
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
describe("buildTreeBlocks — attachments rendering", () => {
|
|
939
|
+
test("tree with no attachments emits no extra section blocks", () => {
|
|
940
|
+
const root: TreeNode = {
|
|
941
|
+
taskId: makeTaskId("aatt0001"),
|
|
942
|
+
agentName: "Lead",
|
|
943
|
+
status: "completed",
|
|
944
|
+
duration: "10s",
|
|
945
|
+
children: [
|
|
946
|
+
{
|
|
947
|
+
taskId: makeTaskId("aatt0002"),
|
|
948
|
+
agentName: "Worker",
|
|
949
|
+
status: "completed",
|
|
950
|
+
duration: "5s",
|
|
951
|
+
slackReplySent: true,
|
|
952
|
+
// No attachments field
|
|
953
|
+
children: [],
|
|
954
|
+
},
|
|
955
|
+
],
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
const blocks = buildTreeBlocks([root]);
|
|
959
|
+
// Exactly one section block (the tree text); no attachment add-ons; no
|
|
960
|
+
// cancel buttons (everything is terminal).
|
|
961
|
+
expect(blocks.length).toBe(1);
|
|
962
|
+
expect(blocks[0].type).toBe("section");
|
|
963
|
+
expect(blocks[0].text.text).not.toContain("Attachments");
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
test("completed node with 2 attachments adds a dedicated attachment block", () => {
|
|
967
|
+
const root: TreeNode = {
|
|
968
|
+
taskId: makeTaskId("aatt0010"),
|
|
969
|
+
agentName: "Lead",
|
|
970
|
+
status: "completed",
|
|
971
|
+
duration: "1m",
|
|
972
|
+
children: [
|
|
973
|
+
{
|
|
974
|
+
taskId: makeTaskId("aatt0011"),
|
|
975
|
+
agentName: "Worker",
|
|
976
|
+
status: "completed",
|
|
977
|
+
duration: "30s",
|
|
978
|
+
slackReplySent: true,
|
|
979
|
+
attachments: [
|
|
980
|
+
mkAttachment({ kind: "url", name: "report", url: "https://x.test/r" }),
|
|
981
|
+
mkAttachment({ kind: "url", name: "log", url: "https://x.test/l" }),
|
|
982
|
+
],
|
|
983
|
+
children: [],
|
|
984
|
+
},
|
|
985
|
+
],
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
const blocks = buildTreeBlocks([root]);
|
|
989
|
+
// 1 tree section + 1 attachment section.
|
|
990
|
+
expect(blocks.length).toBe(2);
|
|
991
|
+
const texts = sectionTexts(blocks);
|
|
992
|
+
// Tree section contains the worker.
|
|
993
|
+
expect(texts[0]).toContain("*Worker*");
|
|
994
|
+
// Attachment section contains the header AND the two attachments.
|
|
995
|
+
const attachmentSection = texts[1];
|
|
996
|
+
expect(attachmentSection).toContain("*Worker*");
|
|
997
|
+
expect(attachmentSection).toContain("Attachments (2):");
|
|
998
|
+
expect(attachmentSection).toContain("https://x.test/r");
|
|
999
|
+
expect(attachmentSection).toContain("https://x.test/l");
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test("non-completed nodes never emit attachment blocks even if attachments is populated", () => {
|
|
1003
|
+
// Watcher won't populate these for non-completed, but guard the renderer.
|
|
1004
|
+
const root: TreeNode = {
|
|
1005
|
+
taskId: makeTaskId("aatt0020"),
|
|
1006
|
+
agentName: "Lead",
|
|
1007
|
+
status: "in_progress",
|
|
1008
|
+
attachments: [mkAttachment({ kind: "url", name: "x", url: "https://x.test" })],
|
|
1009
|
+
children: [],
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
const blocks = buildTreeBlocks([root]);
|
|
1013
|
+
// Tree section + cancel button only.
|
|
1014
|
+
expect(blocks.length).toBe(2);
|
|
1015
|
+
expect(blocks[0].type).toBe("section");
|
|
1016
|
+
expect(blocks[0].text.text).not.toContain("Attachments");
|
|
1017
|
+
expect(blocks[1].type).toBe("actions");
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test("per-tree-message cap: 12 completed children with attachments → 10 blocks + footer", () => {
|
|
1021
|
+
const children: TreeNode[] = Array.from({ length: 12 }, (_, i) => ({
|
|
1022
|
+
taskId: makeTaskId(`aatt${(30 + i).toString().padStart(4, "0")}`),
|
|
1023
|
+
agentName: `Worker${i}`,
|
|
1024
|
+
status: "completed",
|
|
1025
|
+
duration: "1s",
|
|
1026
|
+
slackReplySent: true,
|
|
1027
|
+
attachments: [mkAttachment({ kind: "url", name: `a${i}`, url: `https://x.test/${i}` })],
|
|
1028
|
+
children: [],
|
|
1029
|
+
}));
|
|
1030
|
+
const root: TreeNode = {
|
|
1031
|
+
taskId: makeTaskId("aatt0029"),
|
|
1032
|
+
agentName: "Lead",
|
|
1033
|
+
status: "completed",
|
|
1034
|
+
duration: "1m",
|
|
1035
|
+
children,
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
const blocks = buildTreeBlocks([root]);
|
|
1039
|
+
// 1 tree section + 10 attachment sections + 1 context footer.
|
|
1040
|
+
const sectionBlocks = blocks.filter((b: AnyBlock) => b.type === "section");
|
|
1041
|
+
const contextBlocks = blocks.filter((b: AnyBlock) => b.type === "context");
|
|
1042
|
+
expect(sectionBlocks.length).toBe(1 + 10);
|
|
1043
|
+
expect(contextBlocks.length).toBe(1);
|
|
1044
|
+
const footerText = contextBlocks[0].elements[0].text as string;
|
|
1045
|
+
expect(footerText).toContain("2 more");
|
|
1046
|
+
expect(footerText).toContain("completed task");
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
test("singular footer when exactly one attachment block is hidden", () => {
|
|
1050
|
+
const children: TreeNode[] = Array.from({ length: 11 }, (_, i) => ({
|
|
1051
|
+
taskId: makeTaskId(`aatt${(50 + i).toString().padStart(4, "0")}`),
|
|
1052
|
+
agentName: `Worker${i}`,
|
|
1053
|
+
status: "completed",
|
|
1054
|
+
duration: "1s",
|
|
1055
|
+
slackReplySent: true,
|
|
1056
|
+
attachments: [mkAttachment({ kind: "url", name: `a${i}`, url: `https://x.test/${i}` })],
|
|
1057
|
+
children: [],
|
|
1058
|
+
}));
|
|
1059
|
+
const root: TreeNode = {
|
|
1060
|
+
taskId: makeTaskId("aatt0049"),
|
|
1061
|
+
agentName: "Lead",
|
|
1062
|
+
status: "completed",
|
|
1063
|
+
duration: "1m",
|
|
1064
|
+
children,
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
const blocks = buildTreeBlocks([root]);
|
|
1068
|
+
const contextBlocks = blocks.filter((b: AnyBlock) => b.type === "context");
|
|
1069
|
+
expect(contextBlocks.length).toBe(1);
|
|
1070
|
+
const footerText = contextBlocks[0].elements[0].text as string;
|
|
1071
|
+
expect(footerText).toContain("1 more completed task with attachments");
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getCompletedSlackTasks,
|
|
11
11
|
getInProgressSlackTasks,
|
|
12
12
|
initDb,
|
|
13
|
+
insertTaskAttachment,
|
|
13
14
|
startTask,
|
|
14
15
|
} from "../be/db";
|
|
15
16
|
import {
|
|
@@ -335,6 +336,88 @@ describe("buildTreeNodes", () => {
|
|
|
335
336
|
// Missing task should be skipped, not crash
|
|
336
337
|
expect(nodes.length).toBe(0);
|
|
337
338
|
});
|
|
339
|
+
|
|
340
|
+
test("populates attachments for completed nodes (root + child)", () => {
|
|
341
|
+
const lead = createAgent({ name: "AttachLead", isLead: true, status: "idle" });
|
|
342
|
+
const worker = createAgent({ name: "AttachWorker", isLead: false, status: "idle" });
|
|
343
|
+
|
|
344
|
+
const parent = createTaskExtended("parent with attachments", {
|
|
345
|
+
agentId: lead.id,
|
|
346
|
+
source: "slack",
|
|
347
|
+
slackChannelId: "C_ATTACH",
|
|
348
|
+
slackThreadTs: "2020202020.000001",
|
|
349
|
+
slackUserId: "U_ATTACH",
|
|
350
|
+
});
|
|
351
|
+
const child = createTaskExtended("child with attachments", {
|
|
352
|
+
agentId: worker.id,
|
|
353
|
+
source: "slack",
|
|
354
|
+
parentTaskId: parent.id,
|
|
355
|
+
});
|
|
356
|
+
// Mark both completed so the watcher pulls their attachments.
|
|
357
|
+
completeTask(parent.id, "done");
|
|
358
|
+
completeTask(child.id, "done");
|
|
359
|
+
|
|
360
|
+
insertTaskAttachment({
|
|
361
|
+
taskId: parent.id,
|
|
362
|
+
agentId: lead.id,
|
|
363
|
+
name: "parent-report.pdf",
|
|
364
|
+
kind: "url",
|
|
365
|
+
url: "https://example.com/parent.pdf",
|
|
366
|
+
});
|
|
367
|
+
insertTaskAttachment({
|
|
368
|
+
taskId: child.id,
|
|
369
|
+
agentId: worker.id,
|
|
370
|
+
name: "child-log.txt",
|
|
371
|
+
kind: "agent-fs",
|
|
372
|
+
path: "/logs/child.txt",
|
|
373
|
+
orgId: "org-1",
|
|
374
|
+
driveId: "drive-1",
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const messageTs = "2020202020.000002";
|
|
378
|
+
registerTreeMessage(parent.id, "C_ATTACH", "2020202020.000001", messageTs);
|
|
379
|
+
|
|
380
|
+
const tree = _getTreeMessages().get(messageTs)!;
|
|
381
|
+
const nodes = buildTreeNodes(tree);
|
|
382
|
+
|
|
383
|
+
expect(nodes.length).toBe(1);
|
|
384
|
+
expect(nodes[0].attachments?.length).toBe(1);
|
|
385
|
+
expect(nodes[0].attachments?.[0].name).toBe("parent-report.pdf");
|
|
386
|
+
expect(nodes[0].children.length).toBe(1);
|
|
387
|
+
expect(nodes[0].children[0].attachments?.length).toBe(1);
|
|
388
|
+
expect(nodes[0].children[0].attachments?.[0].orgId).toBe("org-1");
|
|
389
|
+
expect(nodes[0].children[0].attachments?.[0].driveId).toBe("drive-1");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("does NOT fetch attachments for non-completed nodes (pending parent)", () => {
|
|
393
|
+
const agent = createAgent({ name: "NoFetchAgent", isLead: true, status: "idle" });
|
|
394
|
+
const task = createTaskExtended("pending no fetch", {
|
|
395
|
+
agentId: agent.id,
|
|
396
|
+
source: "slack",
|
|
397
|
+
slackChannelId: "C_NOFETCH",
|
|
398
|
+
slackThreadTs: "3030303030.000001",
|
|
399
|
+
slackUserId: "U_NOFETCH",
|
|
400
|
+
});
|
|
401
|
+
// Pre-populate an attachment even though the task is still pending.
|
|
402
|
+
insertTaskAttachment({
|
|
403
|
+
taskId: task.id,
|
|
404
|
+
agentId: agent.id,
|
|
405
|
+
name: "should-not-render.pdf",
|
|
406
|
+
kind: "url",
|
|
407
|
+
url: "https://example.com/notyet.pdf",
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const messageTs = "3030303030.000002";
|
|
411
|
+
registerTreeMessage(task.id, "C_NOFETCH", "3030303030.000001", messageTs);
|
|
412
|
+
|
|
413
|
+
const tree = _getTreeMessages().get(messageTs)!;
|
|
414
|
+
const nodes = buildTreeNodes(tree);
|
|
415
|
+
|
|
416
|
+
expect(nodes.length).toBe(1);
|
|
417
|
+
// Pending tasks should not have attachments populated — the renderer
|
|
418
|
+
// never shows them in that state, so the query is skipped.
|
|
419
|
+
expect(nodes[0].attachments).toBeUndefined();
|
|
420
|
+
});
|
|
338
421
|
});
|
|
339
422
|
|
|
340
423
|
// --- Phase 5: processTreeMessages tests ---
|