@desplega.ai/agent-swarm 1.108.0 → 1.108.2

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 (64) hide show
  1. package/dist/{actions-sdr91cfp.js → actions-1hxdkcp6.js} +6 -6
  2. package/dist/{app-0mcf7mwz.js → app-vn2sy15m.js} +3 -3
  3. package/dist/{assistant-7f3cmabp.js → assistant-sj71nx88.js} +9 -9
  4. package/dist/{boot-reembed-a9btk2eh.js → boot-reembed-nc1dpmdj.js} +4 -4
  5. package/dist/{boot-reembed-47cfre4w.js → boot-reembed-nw9vp9ez.js} +3 -3
  6. package/dist/{boot-scrub-logs-hy8pqhej.js → boot-scrub-logs-g2mpp0cg.js} +2 -2
  7. package/dist/{cli-j9eqkarv.js → cli-1brke24m.js} +3 -3
  8. package/dist/{cli-vemx13wc.js → cli-1wzrmxaj.js} +3 -3
  9. package/dist/{cli-h2qhdwhr.js → cli-27pxayz8.js} +1 -1
  10. package/dist/{cli-a0w6tqyc.js → cli-3p0e74xv.js} +1 -1
  11. package/dist/{cli-xy83cmfc.js → cli-4wrdrmr3.js} +33 -6
  12. package/dist/{cli-vhpa5j27.js → cli-5m4da9px.js} +1 -1
  13. package/dist/{cli-9wv4e7qs.js → cli-6jf8xfnt.js} +7 -3
  14. package/dist/{cli-1zs2dm5w.js → cli-6z0y1339.js} +4 -4
  15. package/dist/{cli-mkn2ryba.js → cli-8gtrpqcw.js} +1 -1
  16. package/dist/{cli-7r40bgvr.js → cli-8kssf4wv.js} +4 -4
  17. package/dist/{cli-nmt32dxp.js → cli-9zz27c9g.js} +1 -1
  18. package/dist/{cli-m0k99m2s.js → cli-ae9hy8yh.js} +1 -1
  19. package/dist/{cli-entbgsd5.js → cli-b4hwyzev.js} +1 -1
  20. package/dist/{cli-3cfvx630.js → cli-b7ytq2z8.js} +2 -2
  21. package/dist/{cli-a8gkr14c.js → cli-bz6qha8p.js} +30 -4
  22. package/dist/{cli-tehpf2fm.js → cli-cr2wze6h.js} +1 -1
  23. package/dist/{cli-psz2xq0n.js → cli-er5kffrx.js} +5 -5
  24. package/dist/{cli-mmaz2btq.js → cli-fxk0as0h.js} +1 -1
  25. package/dist/{cli-kppbkzj4.js → cli-gg6zxrx1.js} +5 -5
  26. package/dist/{cli-cya9knf9.js → cli-hc7hxy2m.js} +2 -2
  27. package/dist/{cli-75b0wmer.js → cli-nt60dmfm.js} +2 -2
  28. package/dist/{cli-apdrt1ch.js → cli-tc19ec02.js} +1 -1
  29. package/dist/{cli-gx7gnvha.js → cli-xekgq1r8.js} +17 -17
  30. package/dist/cli.js +8 -8
  31. package/dist/{commands-4p93q1rf.js → commands-11d7nc0g.js} +2 -2
  32. package/dist/{db-hb0chrmg.js → db-0r0wz8a4.js} +2 -2
  33. package/dist/{handlers-pz3zc83m.js → handlers-v8th05yh.js} +9 -9
  34. package/dist/{hook-mye8ket2.js → hook-9n0np62b.js} +1 -1
  35. package/dist/{http-3nrx1tp1.js → http-pmaqk60c.js} +73 -37
  36. package/dist/{index-x9n06ze1.js → index-35x6ptdt.js} +8 -8
  37. package/dist/{index-17n7z8k8.js → index-9vma4xmv.js} +9 -9
  38. package/dist/{index-6sgpkhby.js → index-c9tx34ke.js} +6 -6
  39. package/dist/{index-rys8ctm6.js → index-vgvvr6yk.js} +7 -7
  40. package/dist/{keepalive-b6eax0y0.js → keepalive-pjd1kjv7.js} +4 -4
  41. package/dist/{lead-v1g4j7he.js → lead-czd1xgxr.js} +17 -17
  42. package/dist/{maintenance-xwf1vf0v.js → maintenance-xjqt3ne7.js} +4 -4
  43. package/dist/{onboard-vy0z8b11.js → onboard-whf6w0mr.js} +2 -2
  44. package/dist/{otel-impl-94sm94jm.js → otel-impl-9n4p19tt.js} +1 -1
  45. package/dist/{pricing-refresh-an9bxjcd.js → pricing-refresh-wg80me14.js} +4 -4
  46. package/dist/{seed-pricing-3t22x5ax.js → seed-pricing-garwba27.js} +3 -3
  47. package/dist/{setup-8hd3jk8s.js → setup-dktff2q2.js} +2 -2
  48. package/dist/{worker-s0xm8naj.js → worker-t26gwn0n.js} +17 -17
  49. package/openapi.json +1 -1
  50. package/package.json +1 -1
  51. package/plugin/skills/download-task-attachment/SKILL.md +59 -0
  52. package/src/be/seed/agent-fs-provision.ts +51 -4
  53. package/src/commands/runner.ts +40 -0
  54. package/src/commands/templates.ts +6 -1
  55. package/src/fs/local-fs-provider.ts +36 -4
  56. package/src/http/poll.ts +24 -2
  57. package/src/telemetry.ts +12 -2
  58. package/src/tests/agent-fs-provision-seeder.test.ts +204 -3
  59. package/src/tests/claude-adapter.test.ts +17 -3
  60. package/src/tests/fs-provider.test.ts +34 -0
  61. package/src/tests/http-api-integration.test.ts +51 -0
  62. package/src/tests/prompt-template-remaining.test.ts +25 -0
  63. package/src/tests/runner-attachments-section.test.ts +55 -0
  64. package/src/tests/telemetry-init.test.ts +23 -0
package/src/http/poll.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  getInboxSummary,
17
17
  getOfferedTasksForAgent,
18
18
  getPendingTaskForAgent,
19
+ getTaskAttachments,
19
20
  getTaskById,
20
21
  getUnassignedTaskIds,
21
22
  getUserById,
@@ -90,6 +91,23 @@ function getRequesterNotes(notes: string | undefined): string | undefined {
90
91
  return typeof notes === "string" && notes.trim().length > 0 ? notes : undefined;
91
92
  }
92
93
 
94
+ /**
95
+ * Slim attachment projection for the `task_assigned` poll trigger — just
96
+ * enough (id, name, mimeType, sizeBytes) for the worker to build a one-shot
97
+ * `/api/fs/tasks/{taskId}/files/{id}/raw` fetch recipe into the dispatch
98
+ * prompt, without shipping the full capabilities/provider blob over poll.
99
+ */
100
+ function attachmentsForTrigger(
101
+ taskId: string,
102
+ ): Array<{ id: string; name: string; mimeType?: string; sizeBytes?: number }> {
103
+ return getTaskAttachments(taskId).map((a) => ({
104
+ id: a.id,
105
+ name: a.name,
106
+ mimeType: a.mimeType,
107
+ sizeBytes: a.sizeBytes,
108
+ }));
109
+ }
110
+
93
111
  // ─── Cursor Commit Endpoint ─────────────────────────────────────────────────
94
112
 
95
113
  const commitCursorsRoute = route({
@@ -266,7 +284,11 @@ export async function handlePoll(
266
284
  trigger: {
267
285
  type: "task_assigned",
268
286
  taskId: pendingTask.id,
269
- task: { ...pendingTask, status: "in_progress" },
287
+ task: {
288
+ ...pendingTask,
289
+ status: "in_progress",
290
+ attachments: attachmentsForTrigger(pendingTask.id),
291
+ },
270
292
  ...(requestedByUser && {
271
293
  requestedBy: {
272
294
  name: requestedByUser.name,
@@ -381,7 +403,7 @@ export async function handlePoll(
381
403
  trigger: {
382
404
  type: "task_assigned",
383
405
  taskId: claimed.id,
384
- task: claimed,
406
+ task: { ...claimed, attachments: attachmentsForTrigger(claimed.id) },
385
407
  },
386
408
  };
387
409
  }
package/src/telemetry.ts CHANGED
@@ -188,13 +188,23 @@ export function track(options: TrackOptions): void {
188
188
  actor_anonymous_id: installationId,
189
189
  properties: {
190
190
  ...(options.properties ?? {}),
191
- // Cloud-cohort signal derived from MCP_BASE_URL at init time.
191
+ // Cloud-cohort signal. Two independent signals OR'd together:
192
+ // `cachedIsCloud` (MCP_BASE_URL points at a host we own, resolved at
193
+ // init time) catches self-host operators who point their swarm at
194
+ // our managed MCP endpoint; `isCloudDeployment()` (SWARM_CLOUD env
195
+ // var, read fresh) catches hosted Swarm Cloud deployments, whose
196
+ // MCP_BASE_URL is the intra-compose `http://api:3013` address and so
197
+ // never matches a cloud hostname on its own — SWARM_CLOUD=true is the
198
+ // signal those deployments actually carry (see agent-swarm-internal's
199
+ // .env.personalization). Growth reporting keys off this exact field
200
+ // (`properties_json.is_cloud` in ClickHouse), so both cohorts must
201
+ // resolve true here, not just in `metadata.is_cloud` below.
192
202
  // Placed at the top level of `properties_json` so ClickHouse can
193
203
  // GROUP BY without descending into nested objects. Spread LAST so
194
204
  // caller-supplied keys can never spoof the cohort classification.
195
205
  // The hostname is intentionally NOT included — telemetry must stay
196
206
  // anonymous, and the boolean is sufficient to split cloud vs self-host.
197
- is_cloud: cachedIsCloud,
207
+ is_cloud: cachedIsCloud || isCloudDeployment(),
198
208
  is_e2b: cachedIsE2b,
199
209
  swarmVersion: pkg.version,
200
210
  },
@@ -38,6 +38,12 @@ type RequestRecord = {
38
38
  hasSignal: boolean;
39
39
  };
40
40
 
41
+ type StubMember = {
42
+ email?: string;
43
+ role?: string;
44
+ user?: { email?: string };
45
+ };
46
+
41
47
  async function removeDbFiles(path: string): Promise<void> {
42
48
  for (const suffix of ["", "-wal", "-shm"]) {
43
49
  await unlink(path + suffix).catch(() => {});
@@ -58,7 +64,10 @@ function configValue(key: string): string | undefined {
58
64
  return getSwarmConfigs({ scope: "global", key })[0]?.value;
59
65
  }
60
66
 
61
- function createFetchStub(records: RequestRecord[]): typeof fetch {
67
+ function createFetchStub(
68
+ records: RequestRecord[],
69
+ options: { members?: StubMember[] } = {},
70
+ ): typeof fetch {
62
71
  return (async (input, init) => {
63
72
  const url = new URL(String(input));
64
73
  const method = init?.method ?? "GET";
@@ -111,6 +120,9 @@ function createFetchStub(records: RequestRecord[]): typeof fetch {
111
120
  if (url.pathname === "/orgs/shared-org/drives" && method === "POST") {
112
121
  return Response.json({ id: "shared-drive", name: "shared" }, { status: 201 });
113
122
  }
123
+ if (url.pathname === "/orgs/shared-org/members" && method === "GET") {
124
+ return Response.json({ members: options.members ?? [] });
125
+ }
114
126
  if (url.pathname === "/orgs/shared-org/members/invite" && method === "POST") {
115
127
  return Response.json({ ok: true });
116
128
  }
@@ -160,8 +172,8 @@ describe("agent-fs provisioning seeder", () => {
160
172
  process.env.AGENT_FS_EMAIL_DOMAIN = "agents.example.test";
161
173
 
162
174
  createUser({
163
- name: "Viewer User",
164
- email: `viewer-${suffix}@example.test`,
175
+ name: "Designer User",
176
+ email: `designer-${suffix}@example.test`,
165
177
  role: "Customer",
166
178
  });
167
179
  createUser({
@@ -169,6 +181,11 @@ describe("agent-fs provisioning seeder", () => {
169
181
  email: `operator-${suffix}@example.test`,
170
182
  role: "Operator",
171
183
  });
184
+ createUser({
185
+ name: "Explicit Viewer User",
186
+ email: `viewer-${suffix}@example.test`,
187
+ role: "read-only contractor",
188
+ });
172
189
  const records: RequestRecord[] = [];
173
190
  setAgentFsProvisionFetchForTests(createFetchStub(records));
174
191
 
@@ -202,6 +219,7 @@ describe("agent-fs provisioning seeder", () => {
202
219
 
203
220
  expect(invites).toEqual(
204
221
  [
222
+ { email: `designer-${suffix}@example.test`, role: "editor" },
205
223
  { email: `operator-${suffix}@example.test`, role: "editor" },
206
224
  { email: `viewer-${suffix}@example.test`, role: "viewer" },
207
225
  ].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
@@ -215,6 +233,189 @@ describe("agent-fs provisioning seeder", () => {
215
233
  expect(records).toEqual([]);
216
234
  });
217
235
 
236
+ test("classifies arbitrary non-viewer human roles as agent-fs editors", async () => {
237
+ const suffix = crypto.randomUUID().slice(0, 8);
238
+ process.env.AGENT_FS_API_URL = "https://agent-fs.example.test/";
239
+ process.env.AGENT_FS_REGISTER_EMAIL = "admin@example.test";
240
+ upsertSwarmConfig({
241
+ scope: "global",
242
+ key: "API_AGENT_FS_API_KEY",
243
+ value: "afs-admin-key",
244
+ isSecret: true,
245
+ });
246
+ upsertSwarmConfig({
247
+ scope: "global",
248
+ key: "AGENT_FS_DEFAULT_ORG_ID",
249
+ value: "shared-org",
250
+ });
251
+ upsertSwarmConfig({
252
+ scope: "global",
253
+ key: "AGENT_FS_DEFAULT_DRIVE_ID",
254
+ value: "shared-drive",
255
+ });
256
+
257
+ createUser({
258
+ name: "Taras Founder",
259
+ email: `taras-founder-${suffix}@example.test`,
260
+ role: "co-founder, CTO",
261
+ });
262
+ createUser({
263
+ name: "Eze Founder",
264
+ email: `eze-founder-${suffix}@example.test`,
265
+ role: "co-founder, CEO",
266
+ });
267
+ createUser({
268
+ name: "Designer Human",
269
+ email: `designer-${suffix}@example.test`,
270
+ role: "designer",
271
+ });
272
+ createUser({
273
+ name: "Unknown Human",
274
+ email: `whatever-${suffix}@example.test`,
275
+ role: "whatever",
276
+ });
277
+
278
+ const records: RequestRecord[] = [];
279
+ setAgentFsProvisionFetchForTests(createFetchStub(records));
280
+
281
+ const result = await runSeeder(agentFsProvisionSeeder, { quiet: true });
282
+
283
+ expect(result.failed).toEqual([]);
284
+ const invites = records
285
+ .filter((r) => r.path === "/orgs/shared-org/members/invite")
286
+ .map((r) => r.body);
287
+ expect(invites).toContainEqual({
288
+ email: `taras-founder-${suffix}@example.test`,
289
+ role: "editor",
290
+ });
291
+ expect(invites).toContainEqual({
292
+ email: `eze-founder-${suffix}@example.test`,
293
+ role: "editor",
294
+ });
295
+ expect(invites).toContainEqual({
296
+ email: `designer-${suffix}@example.test`,
297
+ role: "editor",
298
+ });
299
+ expect(invites).toContainEqual({
300
+ email: `whatever-${suffix}@example.test`,
301
+ role: "editor",
302
+ });
303
+ });
304
+
305
+ test("classifies explicitly viewer human roles as agent-fs viewers", async () => {
306
+ const suffix = crypto.randomUUID().slice(0, 8);
307
+ process.env.AGENT_FS_API_URL = "https://agent-fs.example.test/";
308
+ process.env.AGENT_FS_REGISTER_EMAIL = "admin@example.test";
309
+ upsertSwarmConfig({
310
+ scope: "global",
311
+ key: "API_AGENT_FS_API_KEY",
312
+ value: "afs-admin-key",
313
+ isSecret: true,
314
+ });
315
+ upsertSwarmConfig({
316
+ scope: "global",
317
+ key: "AGENT_FS_DEFAULT_ORG_ID",
318
+ value: "shared-org",
319
+ });
320
+ upsertSwarmConfig({
321
+ scope: "global",
322
+ key: "AGENT_FS_DEFAULT_DRIVE_ID",
323
+ value: "shared-drive",
324
+ });
325
+
326
+ createUser({
327
+ name: "Viewer Human",
328
+ email: `viewer-${suffix}@example.test`,
329
+ role: "Viewer",
330
+ });
331
+ createUser({
332
+ name: "Read Only Human",
333
+ email: `read-only-${suffix}@example.test`,
334
+ role: "read only",
335
+ });
336
+ createUser({
337
+ name: "Guest Human",
338
+ email: `guest-${suffix}@example.test`,
339
+ role: "guest",
340
+ });
341
+
342
+ const records: RequestRecord[] = [];
343
+ setAgentFsProvisionFetchForTests(createFetchStub(records));
344
+
345
+ const result = await runSeeder(agentFsProvisionSeeder, { quiet: true });
346
+
347
+ expect(result.failed).toEqual([]);
348
+ const invites = records
349
+ .filter((r) => r.path === "/orgs/shared-org/members/invite")
350
+ .map((r) => r.body);
351
+ expect(invites).toContainEqual({
352
+ email: `viewer-${suffix}@example.test`,
353
+ role: "viewer",
354
+ });
355
+ expect(invites).toContainEqual({
356
+ email: `read-only-${suffix}@example.test`,
357
+ role: "viewer",
358
+ });
359
+ expect(invites).toContainEqual({
360
+ email: `guest-${suffix}@example.test`,
361
+ role: "viewer",
362
+ });
363
+ });
364
+
365
+ test("does not invite existing members when provisioning would keep or lower their role", async () => {
366
+ const suffix = crypto.randomUUID().slice(0, 8);
367
+ const existingAdmin = `existing-admin-${suffix}@example.test`;
368
+ const existingEditor = `existing-editor-${suffix}@example.test`;
369
+ const existingViewer = `existing-viewer-${suffix}@example.test`;
370
+ const explicitViewer = `explicit-viewer-${suffix}@example.test`;
371
+ process.env.AGENT_FS_API_URL = "https://agent-fs.example.test/";
372
+ process.env.AGENT_FS_REGISTER_EMAIL = "admin@example.test";
373
+ upsertSwarmConfig({
374
+ scope: "global",
375
+ key: "API_AGENT_FS_API_KEY",
376
+ value: "afs-admin-key",
377
+ isSecret: true,
378
+ });
379
+ upsertSwarmConfig({
380
+ scope: "global",
381
+ key: "AGENT_FS_DEFAULT_ORG_ID",
382
+ value: "shared-org",
383
+ });
384
+ upsertSwarmConfig({
385
+ scope: "global",
386
+ key: "AGENT_FS_DEFAULT_DRIVE_ID",
387
+ value: "shared-drive",
388
+ });
389
+
390
+ createUser({ name: "Existing Admin", email: existingAdmin, role: "Customer" });
391
+ createUser({ name: "Existing Editor", email: existingEditor, role: "Designer" });
392
+ createUser({ name: "Existing Viewer", email: existingViewer, role: "Whatever" });
393
+ createUser({ name: "Explicit Viewer", email: explicitViewer, role: "Guest" });
394
+
395
+ const records: RequestRecord[] = [];
396
+ setAgentFsProvisionFetchForTests(
397
+ createFetchStub(records, {
398
+ members: [
399
+ { email: existingAdmin, role: "admin" },
400
+ { user: { email: existingEditor }, role: "editor" },
401
+ { email: existingViewer, role: "viewer" },
402
+ ],
403
+ }),
404
+ );
405
+
406
+ const result = await runSeeder(agentFsProvisionSeeder, { quiet: true });
407
+
408
+ expect(result.failed).toEqual([]);
409
+ const inviteBodies = records
410
+ .filter((r) => r.path === "/orgs/shared-org/members/invite")
411
+ .map((r) => r.body);
412
+
413
+ expect(inviteBodies).not.toContainEqual({ email: existingAdmin, role: "editor" });
414
+ expect(inviteBodies).not.toContainEqual({ email: existingEditor, role: "editor" });
415
+ expect(inviteBodies).toContainEqual({ email: existingViewer, role: "editor" });
416
+ expect(inviteBodies).toContainEqual({ email: explicitViewer, role: "viewer" });
417
+ });
418
+
218
419
  test("provisions agent-scoped credentials through the API-owned bootstrap key", async () => {
219
420
  const suffix = crypto.randomUUID().slice(0, 8);
220
421
  process.env.AGENT_FS_API_URL = "https://agent-fs.example.test/";
@@ -86,6 +86,16 @@ describe("ClaudeSession spawn env — reasoning_effort", () => {
86
86
  let spawnSpy: ReturnType<typeof spyOn>;
87
87
  let spawnedEnvs: Array<Record<string, string> | undefined>;
88
88
 
89
+ // `createSession`/`ClaudeSession` fall back to `process.env` when `config.env`
90
+ // is omitted, and spread it wholesale into the spawned process env. Two
91
+ // problems follow in a credential-less sandbox: (1) `validateClaudeCredentials`
92
+ // throws without CLAUDE_CODE_OAUTH_TOKEN/ANTHROPIC_API_KEY, and (2) any ambient
93
+ // env var sharing a name with what these tests assert on (CLAUDE_CODE_EFFORT_LEVEL,
94
+ // MAX_THINKING_TOKENS) would leak through and pollute the `toBeUndefined()`
95
+ // assertions. Passing an explicit, minimal `env` sidesteps both — deterministic
96
+ // regardless of the ambient process env the test happens to run in.
97
+ const CLEAN_ENV: Record<string, string> = { CLAUDE_CODE_OAUTH_TOKEN: "test-oauth-token" };
98
+
89
99
  beforeEach(() => {
90
100
  spawnedEnvs = [];
91
101
  spawnSpy = spyOn(Bun, "spawn").mockImplementation(((
@@ -103,7 +113,9 @@ describe("ClaudeSession spawn env — reasoning_effort", () => {
103
113
 
104
114
  test("reasoningEffort: 'high' on claude-opus-4-8 sets CLAUDE_CODE_EFFORT_LEVEL", async () => {
105
115
  const adapter = new ClaudeAdapter();
106
- await adapter.createSession(makeConfig({ model: "claude-opus-4-8", reasoningEffort: "high" }));
116
+ await adapter.createSession(
117
+ makeConfig({ model: "claude-opus-4-8", reasoningEffort: "high", env: CLEAN_ENV }),
118
+ );
107
119
 
108
120
  expect(spawnedEnvs).toHaveLength(1);
109
121
  expect(spawnedEnvs[0]?.CLAUDE_CODE_EFFORT_LEVEL).toBe("high");
@@ -111,7 +123,9 @@ describe("ClaudeSession spawn env — reasoning_effort", () => {
111
123
 
112
124
  test("reasoningEffort: 'off' on a legacy budget_tokens-capable model sets MAX_THINKING_TOKENS=0, no effort env", async () => {
113
125
  const adapter = new ClaudeAdapter();
114
- await adapter.createSession(makeConfig({ model: "claude-opus-4-0", reasoningEffort: "off" }));
126
+ await adapter.createSession(
127
+ makeConfig({ model: "claude-opus-4-0", reasoningEffort: "off", env: CLEAN_ENV }),
128
+ );
115
129
 
116
130
  expect(spawnedEnvs).toHaveLength(1);
117
131
  expect(spawnedEnvs[0]?.MAX_THINKING_TOKENS).toBe("0");
@@ -120,7 +134,7 @@ describe("ClaudeSession spawn env — reasoning_effort", () => {
120
134
 
121
135
  test("undefined reasoningEffort leaves spawn env unchanged (no effort/budget keys)", async () => {
122
136
  const adapter = new ClaudeAdapter();
123
- await adapter.createSession(makeConfig({ model: "claude-opus-4-8" }));
137
+ await adapter.createSession(makeConfig({ model: "claude-opus-4-8", env: CLEAN_ENV }));
124
138
 
125
139
  expect(spawnedEnvs).toHaveLength(1);
126
140
  expect(spawnedEnvs[0]?.CLAUDE_CODE_EFFORT_LEVEL).toBeUndefined();
@@ -85,6 +85,40 @@ describe("LocalFsProvider", () => {
85
85
  }
86
86
  });
87
87
 
88
+ test("head() returns the real upload Content-Type, not an extension guess from the storage key", async () => {
89
+ const rootDir = await mkdtemp(join(tmpdir(), "agent-swarm-fs-"));
90
+ try {
91
+ const provider = new LocalFsProvider({ rootDir });
92
+ // Storage key has no extension Bun can sniff correctly from — the JPEG bytes
93
+ // would otherwise be mis-reported (e.g. application/octet-stream or worse).
94
+ const scope = { taskId: "task-1", name: "attachment-blob" };
95
+
96
+ const uploaded = await provider.upload(scope, new Blob([new Uint8Array([1, 2, 3])]), {
97
+ contentType: "image/jpeg",
98
+ });
99
+ expect(uploaded.contentType).toBe("image/jpeg");
100
+
101
+ const head = await provider.head(scope);
102
+ expect(head.contentType).toBe("image/jpeg");
103
+
104
+ // copy() must carry the stored Content-Type forward too.
105
+ const copied = await provider.copy(scope, { taskId: "task-1", name: "attachment-copy" });
106
+ expect(copied.contentType).toBe("image/jpeg");
107
+
108
+ // The sidecar metadata file must not surface as a listed file.
109
+ const listed = await provider.list({ taskId: "task-1" });
110
+ expect(listed.map((item) => item.name).sort()).toEqual([
111
+ "attachment-blob",
112
+ "attachment-copy",
113
+ ]);
114
+
115
+ await provider.delete(scope);
116
+ expect(await provider.exists(scope)).toBe(false);
117
+ } finally {
118
+ await rm(rootDir, { recursive: true, force: true });
119
+ }
120
+ });
121
+
88
122
  test("signedUploadUrl throws a normalized ReadOnly error", async () => {
89
123
  const provider = new LocalFsProvider({
90
124
  rootDir: await mkdtemp(join(tmpdir(), "agent-swarm-fs-")),
@@ -860,6 +860,57 @@ describe("Polling", () => {
860
860
  expect(status).toBe(200);
861
861
  expect(body).toBeDefined();
862
862
  });
863
+
864
+ test("GET /api/poll — task_assigned trigger carries a slim attachments projection", async () => {
865
+ // Dedicated agent + task so this doesn't race other tests' fixtures for
866
+ // the shared worker agents.
867
+ const attachAgent = randomUUID();
868
+ const registered = await post("/api/agents", {
869
+ agentId: attachAgent,
870
+ body: { name: "AttachmentPollWorker" },
871
+ });
872
+ expect(registered.status).toBe(201);
873
+
874
+ const created = await post("/api/tasks", {
875
+ agentId: ids.leadAgent,
876
+ body: { task: "Task with an attachment", agentId: attachAgent },
877
+ });
878
+ expect(created.status).toBe(201);
879
+ const taskId = created.body.id as string;
880
+
881
+ // Upload via the real provider-agnostic route (same one PR #850 added) —
882
+ // this is the endpoint the injected fetch recipe below points at.
883
+ const upload = await fetch(`${BASE}/api/fs/tasks/${taskId}/files?name=IMG_1357.jpeg`, {
884
+ method: "POST",
885
+ headers: {
886
+ Authorization: `Bearer ${TEST_API_KEY}`,
887
+ "x-agent-id": ids.leadAgent,
888
+ "Content-Type": "image/jpeg",
889
+ },
890
+ body: Buffer.from("fake-jpeg-bytes"),
891
+ });
892
+ expect(upload.status).toBe(201);
893
+ const attachment = await upload.json();
894
+
895
+ const { status, body } = await get("/api/poll", { agentId: attachAgent });
896
+ expect(status).toBe(200);
897
+ expect(body.trigger?.type).toBe("task_assigned");
898
+ expect(body.trigger?.taskId).toBe(taskId);
899
+
900
+ const attachments = body.trigger?.task?.attachments;
901
+ expect(attachments).toHaveLength(1);
902
+ expect(attachments[0].id).toBe(attachment.id);
903
+ expect(attachments[0].name).toBe("IMG_1357.jpeg");
904
+ // NOTE: mimeType is whatever the provider's head() reports, which has a
905
+ // known pre-existing bug (local-fs infers it from the storage key rather
906
+ // than the upload's real Content-Type) — not asserted here, out of scope
907
+ // for this fix. What matters for the attachment-read fix is id/name.
908
+ expect(attachments[0].mimeType).toBe(attachment.mimeType);
909
+ // Slim projection — no need to ship the full capabilities/provider blob
910
+ // over every poll response.
911
+ expect(attachments[0]).not.toHaveProperty("capabilities");
912
+ expect(attachments[0]).not.toHaveProperty("providerKey");
913
+ });
863
914
  });
864
915
 
865
916
  // ===========================================================================
@@ -393,6 +393,31 @@ describe("Runner triggers — backward compatibility", () => {
393
393
 
394
394
  Task: "Fix the bug"
395
395
 
396
+ When done, use \`store-progress\` with status: "completed" and include your output.`;
397
+
398
+ expect(result.text).toBe(expected);
399
+ });
400
+
401
+ test("task.trigger.assigned threads attachments_section between task_desc_section and output_instructions", () => {
402
+ const result = resolveTemplate("task.trigger.assigned", {
403
+ work_on_task_cmd: "/work-on-task",
404
+ task_id: "abc123",
405
+ task_desc_section: '\n\nTask: "Read the attached file"',
406
+ attachments_section:
407
+ "\n\n📎 Attachment(s) — fetch directly, no need to discover the storage path yourself:\n- IMG_1357.jpeg: `curl ...`",
408
+ output_instructions:
409
+ '\n\nWhen done, use `store-progress` with status: "completed" and include your output.',
410
+ });
411
+
412
+ expect(result.skipped).toBe(false);
413
+
414
+ const expected = `/work-on-task abc123
415
+
416
+ Task: "Read the attached file"
417
+
418
+ 📎 Attachment(s) — fetch directly, no need to discover the storage path yourself:
419
+ - IMG_1357.jpeg: \`curl ...\`
420
+
396
421
  When done, use \`store-progress\` with status: "completed" and include your output.`;
397
422
 
398
423
  expect(result.text).toBe(expected);
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildAttachmentsSection } from "../commands/runner";
3
+
4
+ describe("buildAttachmentsSection", () => {
5
+ test("returns empty string when there are no attachments", () => {
6
+ expect(buildAttachmentsSection("task-1", undefined)).toBe("");
7
+ expect(buildAttachmentsSection("task-1", null)).toBe("");
8
+ expect(buildAttachmentsSection("task-1", [])).toBe("");
9
+ });
10
+
11
+ test("returns empty string when taskId is missing", () => {
12
+ expect(buildAttachmentsSection(undefined, [{ id: "att-1", name: "photo.jpeg" }])).toBe("");
13
+ });
14
+
15
+ test("builds a one-shot curl recipe against the provider-agnostic raw route", () => {
16
+ const section = buildAttachmentsSection("task-123", [
17
+ { id: "att-1", name: "IMG_1357.jpeg", mimeType: "image/jpeg", sizeBytes: 2816227 },
18
+ ]);
19
+
20
+ expect(section).toContain("IMG_1357.jpeg");
21
+ expect(section).toContain("image/jpeg, 2816227 bytes");
22
+ expect(section).toContain(
23
+ '$MCP_BASE_URL/api/fs/tasks/task-123/files/att-1/raw" -o /tmp/IMG_1357.jpeg',
24
+ );
25
+ // No org/drive discovery should be required — the whole point of the fix.
26
+ expect(section).not.toContain("agent-fs");
27
+ expect(section).toContain("X-Agent-ID: $AGENT_ID");
28
+ expect(section).toContain("Authorization: Bearer ");
29
+ expect(section).toContain("AGENT_SWARM_API_KEY:-$API_KEY");
30
+ });
31
+
32
+ test("handles multiple attachments, one line each", () => {
33
+ const section = buildAttachmentsSection("task-123", [
34
+ { id: "att-1", name: "a.png" },
35
+ { id: "att-2", name: "b.pdf" },
36
+ ]);
37
+
38
+ const lines = section.split("\n").filter((l) => l.startsWith("- "));
39
+ expect(lines).toHaveLength(2);
40
+ expect(lines[0]).toContain("a.png");
41
+ expect(lines[1]).toContain("b.pdf");
42
+ });
43
+
44
+ test("skips malformed attachment entries without throwing", () => {
45
+ const section = buildAttachmentsSection("task-123", [
46
+ { id: "att-1" }, // missing name — id used as fallback name
47
+ { name: "no-id.txt" }, // missing id — dropped
48
+ "not-an-object",
49
+ null,
50
+ ]);
51
+
52
+ expect(section).toContain("att-1");
53
+ expect(section).not.toContain("no-id.txt");
54
+ });
55
+ });
@@ -457,6 +457,29 @@ describe("initTelemetry", () => {
457
457
  expect(properties.mcp_host).toBeUndefined();
458
458
  });
459
459
 
460
+ test("hosted Swarm Cloud shape (intra-compose MCP_BASE_URL + SWARM_CLOUD=true) → properties.is_cloud=true", async () => {
461
+ // Mirrors agent-swarm-internal's Hetzner compose template: MCP_BASE_URL
462
+ // is the intra-compose service address (never a cloud hostname), and
463
+ // SWARM_CLOUD=true is seeded via .env.personalization. The hostname
464
+ // heuristic alone would misclassify this as self-host.
465
+ process.env.MCP_BASE_URL = "http://api:3013";
466
+ process.env.SWARM_CLOUD = "true";
467
+ await initTelemetry(
468
+ "api-server",
469
+ async () => "install_hosted_cloud_test",
470
+ async () => {},
471
+ );
472
+
473
+ track({ event: "server.started", properties: { port: 3013 } });
474
+ await new Promise((r) => setTimeout(r, 0));
475
+
476
+ const properties = (captured as { properties: Record<string, unknown> }).properties;
477
+ expect(properties.is_cloud).toBe(true);
478
+ expect(properties.mcp_host).toBeUndefined();
479
+
480
+ delete process.env.SWARM_CLOUD;
481
+ });
482
+
460
483
  test("caller properties cannot override is_cloud", async () => {
461
484
  // Defense-in-depth: even if a caller passes through user-supplied
462
485
  // values, the cohort signal shipped on every event must come from