@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.
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Regression coverage for the `store-progress` MCP tool handler — specifically
3
+ * the path that inserts `task_attachments` rows.
4
+ *
5
+ * The Phase 1 + Phase 2a follow-up handler gated the insert behind `!isTerminal`
6
+ * (alongside the no-op short-circuit for status writes), which meant any call
7
+ * to `store-progress(taskId, attachments=[...])` against an already-completed
8
+ * task silently dropped every attachment while still returning `success: true`.
9
+ * The Lead's full smoke battery targets completed parent tasks, so the
10
+ * regression made Phase 1 storage look broken in production.
11
+ *
12
+ * These tests pull the handler straight out of the SDK registry (same pattern
13
+ * as `create-page-tool.test.ts`) and exercise:
14
+ * 1. attachment insert on an in-progress task (smoke baseline)
15
+ * 2. attachment insert on a COMPLETED task — the regression scenario
16
+ * 3. agent-fs attachment with optional `orgId` + `driveId` round-trips
17
+ * 4. agent-fs attachment without `orgId` / `driveId` still inserts (both
18
+ * shapes mandated by the task brief)
19
+ */
20
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
21
+ import crypto from "node:crypto";
22
+ import { unlink } from "node:fs/promises";
23
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
+ import {
25
+ closeDb,
26
+ completeTask,
27
+ createAgent,
28
+ createTaskExtended,
29
+ getDb,
30
+ getTaskAttachments,
31
+ initDb,
32
+ startTask,
33
+ upsertSwarmConfig,
34
+ } from "../be/db";
35
+ import { registerStoreProgressTool } from "../tools/store-progress";
36
+
37
+ const TEST_DB_PATH = "./test-store-progress-attachments-handler.sqlite";
38
+
39
+ type RegisteredTool = {
40
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
41
+ };
42
+
43
+ type StoreProgressResult = {
44
+ structuredContent: {
45
+ success: boolean;
46
+ message: string;
47
+ wasNoOp?: boolean;
48
+ yourAgentId?: string;
49
+ };
50
+ };
51
+
52
+ function buildServer() {
53
+ const server = new McpServer({
54
+ name: "store-progress-handler-test",
55
+ version: "1.0.0",
56
+ });
57
+ registerStoreProgressTool(server);
58
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
59
+ ._registeredTools;
60
+ const tool = registered["store-progress"];
61
+ if (!tool) throw new Error("store-progress tool not registered");
62
+ return tool;
63
+ }
64
+
65
+ describe("store-progress handler — attachments insert path", () => {
66
+ let agentId: string;
67
+
68
+ beforeAll(async () => {
69
+ for (const suffix of ["", "-wal", "-shm"]) {
70
+ try {
71
+ await unlink(`${TEST_DB_PATH}${suffix}`);
72
+ } catch {}
73
+ }
74
+ initDb(TEST_DB_PATH);
75
+ const agent = createAgent({
76
+ name: "Handler Attachments Worker",
77
+ description: "Agent for handler-level attachment tests",
78
+ role: "worker",
79
+ isLead: false,
80
+ status: "busy",
81
+ maxTasks: 1,
82
+ capabilities: [],
83
+ });
84
+ agentId = agent.id;
85
+ });
86
+
87
+ afterAll(async () => {
88
+ closeDb();
89
+ for (const suffix of ["", "-wal", "-shm"]) {
90
+ try {
91
+ await unlink(`${TEST_DB_PATH}${suffix}`);
92
+ } catch {}
93
+ }
94
+ });
95
+
96
+ function buildMeta() {
97
+ return {
98
+ sessionId: `session-${crypto.randomUUID()}`,
99
+ requestInfo: { headers: { "x-agent-id": agentId } },
100
+ };
101
+ }
102
+
103
+ test("inserts attachment row on an in-progress task (baseline)", async () => {
104
+ const task = createTaskExtended("handler in-progress baseline", {
105
+ agentId,
106
+ source: "mcp",
107
+ priority: 50,
108
+ });
109
+ startTask(task.id, agentId);
110
+
111
+ const tool = buildServer();
112
+ const result = (await tool.handler(
113
+ {
114
+ taskId: task.id,
115
+ progress: "smoke",
116
+ attachments: [{ kind: "url", name: "example", url: "https://example.com/baseline" }],
117
+ },
118
+ buildMeta(),
119
+ )) as StoreProgressResult;
120
+
121
+ expect(result.structuredContent.success).toBe(true);
122
+ const rows = getTaskAttachments(task.id);
123
+ expect(rows.length).toBe(1);
124
+ expect(rows[0].kind).toBe("url");
125
+ expect(rows[0].url).toBe("https://example.com/baseline");
126
+ });
127
+
128
+ test("inserts attachment row on an ALREADY-COMPLETED task (PR #542 regression)", async () => {
129
+ const task = createTaskExtended("handler post-completion attachment", {
130
+ agentId,
131
+ source: "mcp",
132
+ priority: 50,
133
+ });
134
+ startTask(task.id, agentId);
135
+ const completed = completeTask(task.id, "done");
136
+ expect(completed?.status).toBe("completed");
137
+
138
+ // Lead's smoke shape: just a minimal URL attachment, no status field, no
139
+ // progress text. Pre-fix this returned `success: true` and inserted zero
140
+ // rows. Post-fix the row is appended in place.
141
+ const tool = buildServer();
142
+ const result = (await tool.handler(
143
+ {
144
+ taskId: task.id,
145
+ attachments: [{ kind: "url", name: "post-completion link", url: "https://example.com/x" }],
146
+ },
147
+ buildMeta(),
148
+ )) as StoreProgressResult;
149
+
150
+ expect(result.structuredContent.success).toBe(true);
151
+ const rows = getTaskAttachments(task.id);
152
+ expect(rows.length).toBe(1);
153
+ expect(rows[0].kind).toBe("url");
154
+ expect(rows[0].name).toBe("post-completion link");
155
+ });
156
+
157
+ test("agent-fs attachment with optional orgId + driveId round-trips through the handler", async () => {
158
+ const task = createTaskExtended("handler agent-fs with org/drive", {
159
+ agentId,
160
+ source: "mcp",
161
+ priority: 50,
162
+ });
163
+ startTask(task.id, agentId);
164
+
165
+ const tool = buildServer();
166
+ const result = (await tool.handler(
167
+ {
168
+ taskId: task.id,
169
+ attachments: [
170
+ {
171
+ kind: "agent-fs",
172
+ name: "doc.md",
173
+ path: "/thoughts/doc.md",
174
+ orgId: "org-abc",
175
+ driveId: "drive-xyz",
176
+ intent: "linkable artifact",
177
+ },
178
+ ],
179
+ },
180
+ buildMeta(),
181
+ )) as StoreProgressResult;
182
+
183
+ expect(result.structuredContent.success).toBe(true);
184
+ const rows = getTaskAttachments(task.id);
185
+ expect(rows.length).toBe(1);
186
+ expect(rows[0].kind).toBe("agent-fs");
187
+ expect(rows[0].path).toBe("/thoughts/doc.md");
188
+ expect(rows[0].orgId).toBe("org-abc");
189
+ expect(rows[0].driveId).toBe("drive-xyz");
190
+ });
191
+
192
+ test("agent-fs attachment WITHOUT orgId / driveId still inserts (legacy shape)", async () => {
193
+ const task = createTaskExtended("handler agent-fs without org/drive", {
194
+ agentId,
195
+ source: "mcp",
196
+ priority: 50,
197
+ });
198
+ startTask(task.id, agentId);
199
+
200
+ const tool = buildServer();
201
+ const result = (await tool.handler(
202
+ {
203
+ taskId: task.id,
204
+ attachments: [
205
+ {
206
+ kind: "agent-fs",
207
+ name: "legacy.md",
208
+ path: "/thoughts/legacy.md",
209
+ },
210
+ ],
211
+ },
212
+ buildMeta(),
213
+ )) as StoreProgressResult;
214
+
215
+ expect(result.structuredContent.success).toBe(true);
216
+ const rows = getTaskAttachments(task.id);
217
+ expect(rows.length).toBe(1);
218
+ expect(rows[0].kind).toBe("agent-fs");
219
+ expect(rows[0].path).toBe("/thoughts/legacy.md");
220
+ expect(rows[0].orgId).toBeUndefined();
221
+ expect(rows[0].driveId).toBeUndefined();
222
+ });
223
+
224
+ describe("agent-fs orgId/driveId auto-resolve from swarm config", () => {
225
+ // Per-test cleanup so config rows from one case don't leak into the next.
226
+ function clearSwarmConfig() {
227
+ getDb().run("DELETE FROM swarm_config");
228
+ }
229
+
230
+ test("missing orgId/driveId fills in from global swarm config", async () => {
231
+ clearSwarmConfig();
232
+ upsertSwarmConfig({
233
+ scope: "global",
234
+ key: "AGENT_FS_DEFAULT_ORG_ID",
235
+ value: "global-org",
236
+ });
237
+ upsertSwarmConfig({
238
+ scope: "global",
239
+ key: "AGENT_FS_DEFAULT_DRIVE_ID",
240
+ value: "global-drive",
241
+ });
242
+
243
+ const task = createTaskExtended("handler agent-fs auto-resolve global", {
244
+ agentId,
245
+ source: "mcp",
246
+ priority: 50,
247
+ });
248
+ startTask(task.id, agentId);
249
+
250
+ const tool = buildServer();
251
+ const result = (await tool.handler(
252
+ {
253
+ taskId: task.id,
254
+ attachments: [
255
+ {
256
+ kind: "agent-fs",
257
+ name: "doc.md",
258
+ path: "/thoughts/auto.md",
259
+ },
260
+ ],
261
+ },
262
+ buildMeta(),
263
+ )) as StoreProgressResult;
264
+
265
+ expect(result.structuredContent.success).toBe(true);
266
+ const rows = getTaskAttachments(task.id);
267
+ expect(rows.length).toBe(1);
268
+ expect(rows[0].kind).toBe("agent-fs");
269
+ expect(rows[0].orgId).toBe("global-org");
270
+ expect(rows[0].driveId).toBe("global-drive");
271
+ });
272
+
273
+ test("agent-scoped config wins over global (scope precedence)", async () => {
274
+ clearSwarmConfig();
275
+ upsertSwarmConfig({
276
+ scope: "global",
277
+ key: "AGENT_FS_DEFAULT_ORG_ID",
278
+ value: "global-org",
279
+ });
280
+ upsertSwarmConfig({
281
+ scope: "global",
282
+ key: "AGENT_FS_DEFAULT_DRIVE_ID",
283
+ value: "global-drive",
284
+ });
285
+ upsertSwarmConfig({
286
+ scope: "agent",
287
+ scopeId: agentId,
288
+ key: "AGENT_FS_DEFAULT_ORG_ID",
289
+ value: "agent-org",
290
+ });
291
+ upsertSwarmConfig({
292
+ scope: "agent",
293
+ scopeId: agentId,
294
+ key: "AGENT_FS_DEFAULT_DRIVE_ID",
295
+ value: "agent-drive",
296
+ });
297
+
298
+ const task = createTaskExtended("handler agent-fs auto-resolve agent-scope", {
299
+ agentId,
300
+ source: "mcp",
301
+ priority: 50,
302
+ });
303
+ startTask(task.id, agentId);
304
+
305
+ const tool = buildServer();
306
+ const result = (await tool.handler(
307
+ {
308
+ taskId: task.id,
309
+ attachments: [
310
+ {
311
+ kind: "agent-fs",
312
+ name: "scoped.md",
313
+ path: "/thoughts/scoped.md",
314
+ },
315
+ ],
316
+ },
317
+ buildMeta(),
318
+ )) as StoreProgressResult;
319
+
320
+ expect(result.structuredContent.success).toBe(true);
321
+ const rows = getTaskAttachments(task.id);
322
+ expect(rows.length).toBe(1);
323
+ expect(rows[0].orgId).toBe("agent-org");
324
+ expect(rows[0].driveId).toBe("agent-drive");
325
+ });
326
+
327
+ test("missing config + missing row IDs leaves null IDs (no throw, renderer falls back)", async () => {
328
+ clearSwarmConfig();
329
+
330
+ const task = createTaskExtended("handler agent-fs no config no ids", {
331
+ agentId,
332
+ source: "mcp",
333
+ priority: 50,
334
+ });
335
+ startTask(task.id, agentId);
336
+
337
+ const tool = buildServer();
338
+ const result = (await tool.handler(
339
+ {
340
+ taskId: task.id,
341
+ attachments: [
342
+ {
343
+ kind: "agent-fs",
344
+ name: "no-ids.md",
345
+ path: "/thoughts/no-ids.md",
346
+ },
347
+ ],
348
+ },
349
+ buildMeta(),
350
+ )) as StoreProgressResult;
351
+
352
+ expect(result.structuredContent.success).toBe(true);
353
+ const rows = getTaskAttachments(task.id);
354
+ expect(rows.length).toBe(1);
355
+ expect(rows[0].orgId).toBeUndefined();
356
+ expect(rows[0].driveId).toBeUndefined();
357
+ });
358
+
359
+ test("per-row IDs always win — config defaults never overwrite explicit values", async () => {
360
+ clearSwarmConfig();
361
+ upsertSwarmConfig({
362
+ scope: "global",
363
+ key: "AGENT_FS_DEFAULT_ORG_ID",
364
+ value: "global-org",
365
+ });
366
+ upsertSwarmConfig({
367
+ scope: "global",
368
+ key: "AGENT_FS_DEFAULT_DRIVE_ID",
369
+ value: "global-drive",
370
+ });
371
+
372
+ const task = createTaskExtended("handler agent-fs per-row wins", {
373
+ agentId,
374
+ source: "mcp",
375
+ priority: 50,
376
+ });
377
+ startTask(task.id, agentId);
378
+
379
+ const tool = buildServer();
380
+ const result = (await tool.handler(
381
+ {
382
+ taskId: task.id,
383
+ attachments: [
384
+ {
385
+ kind: "agent-fs",
386
+ name: "explicit.md",
387
+ path: "/thoughts/explicit.md",
388
+ orgId: "row-org",
389
+ driveId: "row-drive",
390
+ },
391
+ ],
392
+ },
393
+ buildMeta(),
394
+ )) as StoreProgressResult;
395
+
396
+ expect(result.structuredContent.success).toBe(true);
397
+ const rows = getTaskAttachments(task.id);
398
+ expect(rows.length).toBe(1);
399
+ expect(rows[0].orgId).toBe("row-org");
400
+ expect(rows[0].driveId).toBe("row-drive");
401
+ });
402
+
403
+ test("partial row IDs — only the missing one is filled from config", async () => {
404
+ clearSwarmConfig();
405
+ upsertSwarmConfig({
406
+ scope: "global",
407
+ key: "AGENT_FS_DEFAULT_ORG_ID",
408
+ value: "global-org",
409
+ });
410
+ upsertSwarmConfig({
411
+ scope: "global",
412
+ key: "AGENT_FS_DEFAULT_DRIVE_ID",
413
+ value: "global-drive",
414
+ });
415
+
416
+ const task = createTaskExtended("handler agent-fs partial fill", {
417
+ agentId,
418
+ source: "mcp",
419
+ priority: 50,
420
+ });
421
+ startTask(task.id, agentId);
422
+
423
+ const tool = buildServer();
424
+ const result = (await tool.handler(
425
+ {
426
+ taskId: task.id,
427
+ attachments: [
428
+ {
429
+ kind: "agent-fs",
430
+ name: "partial.md",
431
+ path: "/thoughts/partial.md",
432
+ orgId: "row-org",
433
+ // driveId omitted on purpose
434
+ },
435
+ ],
436
+ },
437
+ buildMeta(),
438
+ )) as StoreProgressResult;
439
+
440
+ expect(result.structuredContent.success).toBe(true);
441
+ const rows = getTaskAttachments(task.id);
442
+ expect(rows.length).toBe(1);
443
+ expect(rows[0].orgId).toBe("row-org");
444
+ expect(rows[0].driveId).toBe("global-drive");
445
+ });
446
+ });
447
+
448
+ test("status='completed' on a terminal task still no-ops but attachments append", async () => {
449
+ // Lead's other shape: re-issue completion with attachments piggy-backed.
450
+ // The no-op short-circuit must still fire for the status write (no
451
+ // duplicate completion / follow-up), but attachments are append-only and
452
+ // dedup-safe so they land.
453
+ const task = createTaskExtended("handler retry completion with attachments", {
454
+ agentId,
455
+ source: "mcp",
456
+ priority: 50,
457
+ });
458
+ startTask(task.id, agentId);
459
+ completeTask(task.id, "first");
460
+
461
+ const tool = buildServer();
462
+ const result = (await tool.handler(
463
+ {
464
+ taskId: task.id,
465
+ status: "completed",
466
+ output: "second (ignored)",
467
+ attachments: [
468
+ { kind: "url", name: "after first completion", url: "https://example.com/retry" },
469
+ ],
470
+ },
471
+ buildMeta(),
472
+ )) as StoreProgressResult;
473
+
474
+ expect(result.structuredContent.success).toBe(true);
475
+ expect(result.structuredContent.wasNoOp).toBe(true);
476
+ const rows = getTaskAttachments(task.id);
477
+ expect(rows.length).toBe(1);
478
+ expect(rows[0].url).toBe("https://example.com/retry");
479
+ });
480
+ });
@@ -309,4 +309,45 @@ describe("task_attachments — Phase 1 (pointer-based, append-only)", () => {
309
309
  expect(rows.length).toBe(1);
310
310
  expect(TaskAttachmentSchema.safeParse(rows[0]).success).toBe(true);
311
311
  });
312
+
313
+ // Phase 2a follow-up: agent-fs attachments can now carry org_id / drive_id
314
+ // so renderers (Slack, UI) can build a public live-host URL.
315
+ test("agent-fs attachment persists orgId and driveId across the round-trip", () => {
316
+ const task = newTask("agent-fs org/drive round-trip");
317
+ const stored = insertTaskAttachment({
318
+ taskId: task.id,
319
+ agentId,
320
+ name: "doc.md",
321
+ kind: "agent-fs",
322
+ path: "/thoughts/doc.md",
323
+ orgId: "org-abc",
324
+ driveId: "drive-xyz",
325
+ });
326
+ expect(stored.orgId).toBe("org-abc");
327
+ expect(stored.driveId).toBe("drive-xyz");
328
+
329
+ const rows = getTaskAttachments(task.id);
330
+ const target = rows.find((r) => r.id === stored.id);
331
+ expect(target?.orgId).toBe("org-abc");
332
+ expect(target?.driveId).toBe("drive-xyz");
333
+ expect(TaskAttachmentSchema.safeParse(target).success).toBe(true);
334
+ });
335
+
336
+ test("AttachmentInputSchema accepts agent-fs with optional orgId/driveId", () => {
337
+ const withIds = AttachmentInputSchema.safeParse({
338
+ kind: "agent-fs",
339
+ name: "doc",
340
+ path: "/x",
341
+ orgId: "o",
342
+ driveId: "d",
343
+ });
344
+ expect(withIds.success).toBe(true);
345
+
346
+ const withoutIds = AttachmentInputSchema.safeParse({
347
+ kind: "agent-fs",
348
+ name: "doc",
349
+ path: "/x",
350
+ });
351
+ expect(withoutIds.success).toBe(true);
352
+ });
312
353
  });
@@ -8,6 +8,7 @@ import {
8
8
  getAgentById,
9
9
  getDb,
10
10
  getLeadAgent,
11
+ getResolvedConfig,
11
12
  getSessionLogsByTaskId,
12
13
  getTaskAttachments,
13
14
  getTaskById,
@@ -144,29 +145,46 @@ export const registerStoreProgressTool = (server: McpServer) => {
144
145
  let updatedTask = existingTask;
145
146
  const isTerminal = ["completed", "failed", "cancelled"].includes(existingTask.status);
146
147
 
147
- // Idempotency guard: short-circuit terminal-status writes (completed/failed)
148
- // BEFORE any side-effects fire (event emission, memory write, follow-up task,
149
- // business-use ensure). Without this, a multi-session race causes duplicate
150
- // follow-up tasks to lead, vector index pollution, and spurious BU events.
151
- // First-call-wins: existing output / finishedAt are preserved.
152
- if (status && isTerminal) {
153
- return {
154
- success: true,
155
- message:
156
- `Task "${taskId}" is already ${existingTask.status}; treating as no-op. ` +
157
- `Existing output preserved (first-call-wins).`,
158
- task: existingTask,
159
- wasNoOp: true,
160
- };
161
- }
162
-
163
148
  // Attachments — pointer-based, append-only. Insert each row inside
164
149
  // this transaction; the helper dedups by sha256 (when present) or by
165
150
  // (kind, pointer, name), so idempotent re-calls don't fan out
166
- // duplicates. Intentionally NOT reached on the terminal-status no-op
167
- // short-circuit above (per Phase 1 spec).
168
- if (attachments && attachments.length > 0 && !isTerminal) {
151
+ // duplicates. Run BEFORE the terminal-status short-circuit: smoke
152
+ // tests and post-completion artifact uploads target already-completed
153
+ // tasks, and the schema explicitly documents that attachments "may be
154
+ // sent on any call (progress or completion) and accumulate across
155
+ // calls." Status writes still no-op on terminal tasks (see below);
156
+ // attachment writes don't change task state, so they're safe to
157
+ // accept on any status.
158
+ if (attachments && attachments.length > 0) {
159
+ // Resolve agent-fs default org/drive IDs from swarm config lazily —
160
+ // only if at least one `agent-fs` row arrives with missing IDs.
161
+ // Scope precedence is `getResolvedConfig`'s usual repo > agent >
162
+ // global; we pass the calling agent's id so agent-scoped overrides
163
+ // win. Per-row IDs always take precedence over the config defaults.
164
+ // Env-var fallback in `constants.ts` remains the secondary path for
165
+ // self-hosters who deploy without a config DB.
166
+ let agentFsDefaults: { orgId?: string; driveId?: string } | null = null;
167
+ const resolveAgentFsDefaults = (): { orgId?: string; driveId?: string } => {
168
+ if (agentFsDefaults !== null) return agentFsDefaults;
169
+ const configs = getResolvedConfig(requestInfo.agentId ?? undefined);
170
+ const orgId = configs.find((c) => c.key === "AGENT_FS_DEFAULT_ORG_ID")?.value;
171
+ const driveId = configs.find((c) => c.key === "AGENT_FS_DEFAULT_DRIVE_ID")?.value;
172
+ agentFsDefaults = {
173
+ orgId: orgId && orgId.length > 0 ? orgId : undefined,
174
+ driveId: driveId && driveId.length > 0 ? driveId : undefined,
175
+ };
176
+ return agentFsDefaults;
177
+ };
178
+
169
179
  for (const a of attachments) {
180
+ let orgId = a.kind === "agent-fs" ? a.orgId : undefined;
181
+ let driveId = a.kind === "agent-fs" ? a.driveId : undefined;
182
+ if (a.kind === "agent-fs" && (!orgId || !driveId)) {
183
+ const defaults = resolveAgentFsDefaults();
184
+ orgId = orgId || defaults.orgId;
185
+ driveId = driveId || defaults.driveId;
186
+ }
187
+
170
188
  insertTaskAttachment({
171
189
  taskId,
172
190
  agentId: requestInfo.agentId ?? null,
@@ -175,6 +193,8 @@ export const registerStoreProgressTool = (server: McpServer) => {
175
193
  url: a.kind === "url" ? a.url : undefined,
176
194
  path: a.kind === "agent-fs" || a.kind === "shared-fs" ? a.path : undefined,
177
195
  pageId: a.kind === "page" ? a.pageId : undefined,
196
+ orgId,
197
+ driveId,
178
198
  mimeType: a.mimeType,
179
199
  sizeBytes: a.sizeBytes,
180
200
  sha256: a.sha256,
@@ -185,6 +205,22 @@ export const registerStoreProgressTool = (server: McpServer) => {
185
205
  }
186
206
  }
187
207
 
208
+ // Idempotency guard: short-circuit terminal-status writes (completed/failed)
209
+ // BEFORE any side-effects fire (event emission, memory write, follow-up task,
210
+ // business-use ensure). Without this, a multi-session race causes duplicate
211
+ // follow-up tasks to lead, vector index pollution, and spurious BU events.
212
+ // First-call-wins: existing output / finishedAt are preserved.
213
+ if (status && isTerminal) {
214
+ return {
215
+ success: true,
216
+ message:
217
+ `Task "${taskId}" is already ${existingTask.status}; treating as no-op. ` +
218
+ `Existing output preserved (first-call-wins).`,
219
+ task: existingTask,
220
+ wasNoOp: true,
221
+ };
222
+ }
223
+
188
224
  // Update progress if provided (with deduplication)
189
225
  // Skip for tasks already in a terminal state to prevent zombie revival
190
226
  if (progress && !isTerminal) {
package/src/types.ts CHANGED
@@ -243,6 +243,20 @@ export const AttachmentInputSchema = z.discriminatedUnion("kind", [
243
243
  z.object({
244
244
  kind: z.literal("agent-fs"),
245
245
  path: z.string().min(1).describe("agent-fs path the attachment points at."),
246
+ orgId: z
247
+ .string()
248
+ .min(1)
249
+ .optional()
250
+ .describe(
251
+ "agent-fs org id — paired with `driveId` lets the renderer build a public live-host URL.",
252
+ ),
253
+ driveId: z
254
+ .string()
255
+ .min(1)
256
+ .optional()
257
+ .describe(
258
+ "agent-fs drive id — paired with `orgId` lets the renderer build a public live-host URL.",
259
+ ),
246
260
  ...attachmentCommonFields,
247
261
  }),
248
262
  z.object({
@@ -272,6 +286,9 @@ export const TaskAttachmentSchema = z.object({
272
286
  url: z.string().optional(),
273
287
  path: z.string().optional(),
274
288
  pageId: z.string().optional(),
289
+ // agent-fs only — pair with `path` to build a public live-host URL.
290
+ orgId: z.string().optional(),
291
+ driveId: z.string().optional(),
275
292
  mimeType: z.string().optional(),
276
293
  sizeBytes: z.number().int().min(0).optional(),
277
294
  sha256: z.string().optional(),
@@ -491,7 +508,10 @@ export type AgentLatestModel = z.infer<typeof AgentLatestModelSchema>;
491
508
  export const AgentCredStatusSchema = z.object({
492
509
  ready: z.boolean(),
493
510
  missing: z.array(z.string()).default([]),
494
- satisfiedBy: z.enum(["env", "file", "side-effect-pending"]).nullable().default(null),
511
+ satisfiedBy: z
512
+ .enum(["env", "file", "side-effect-pending", "sdk-delegated"])
513
+ .nullable()
514
+ .default(null),
495
515
  hint: z.string().nullable().default(null),
496
516
  liveTest: AgentCredStatusLiveTestSchema.nullable().default(null),
497
517
  latestModel: AgentLatestModelSchema.nullable().default(null),