@desplega.ai/agent-swarm 1.123.1 → 1.124.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.
Files changed (80) hide show
  1. package/README.md +2 -1
  2. package/dist/{actions-3ntn8s3f.js → actions-z3d0fk7z.js} +5 -5
  3. package/dist/{app-pej7g57x.js → app-q2rc9tdr.js} +3 -3
  4. package/dist/{assistant-pxa36zjn.js → assistant-3stjq7fz.js} +8 -8
  5. package/dist/{boot-reembed-wdwde4vr.js → boot-reembed-5867whnb.js} +3 -3
  6. package/dist/{boot-reembed-ybq960bh.js → boot-reembed-v6r1df32.js} +4 -4
  7. package/dist/{boot-scrub-logs-ndqxg6e3.js → boot-scrub-logs-8b8jegk3.js} +2 -2
  8. package/dist/{cli-xv09zc8e.js → cli-0925phzv.js} +1 -1
  9. package/dist/{cli-256kb8p5.js → cli-2yr6edr7.js} +4 -4
  10. package/dist/{cli-x2hk4m81.js → cli-3z4q09tn.js} +54 -9
  11. package/dist/{cli-7yn0n4fp.js → cli-5jn6wnd9.js} +8 -8
  12. package/dist/{cli-82eq5zf5.js → cli-92bqgbgy.js} +1 -1
  13. package/dist/{cli-r07fhvsk.js → cli-dq2ssyf0.js} +5 -5
  14. package/dist/{cli-kqgkfpz7.js → cli-gkvf8d83.js} +1 -1
  15. package/dist/{cli-7qvpf14x.js → cli-gzjsamwc.js} +1 -1
  16. package/dist/{cli-333x50bh.js → cli-h89g0qrd.js} +1 -1
  17. package/dist/{cli-g4bfr1e7.js → cli-jp4mdj9d.js} +2 -2
  18. package/dist/{cli-2e5t0yjj.js → cli-kk58tg33.js} +7 -7
  19. package/dist/{cli-mb3gz7qr.js → cli-mcez7z4p.js} +1 -1
  20. package/dist/{cli-ca32n52j.js → cli-mmk5vagg.js} +2 -2
  21. package/dist/{cli-1kv5sydm.js → cli-p0ec4vv6.js} +1 -1
  22. package/dist/{cli-zvbxe7pf.js → cli-p0zgv8pj.js} +1 -1
  23. package/dist/{cli-mntyk3cr.js → cli-perb20ma.js} +1 -1
  24. package/dist/{cli-7rk0bbfr.js → cli-qf7acvyr.js} +2 -2
  25. package/dist/{cli-1cah98w1.js → cli-qwsagsmg.js} +1 -1
  26. package/dist/{cli-pjvdm39a.js → cli-sqv6c9b8.js} +5 -5
  27. package/dist/{cli-vw0acwe7.js → cli-vm20sh8d.js} +3 -3
  28. package/dist/{cli-sj43m5z0.js → cli-wn3m6q54.js} +3 -3
  29. package/dist/{cli-hgkf9zmd.js → cli-x268say8.js} +125 -58
  30. package/dist/{cli-0pmkqg1d.js → cli-y43n987v.js} +1 -1
  31. package/dist/{cli-qrnhnhma.js → cli-ykthjw29.js} +4 -4
  32. package/dist/{cli-k3yfwe8g.js → cli-zxb72ctk.js} +2 -2
  33. package/dist/cli.js +9 -9
  34. package/dist/{commands-5p0erxye.js → commands-sda76yn8.js} +2 -2
  35. package/dist/{db-yp9e9nvs.js → db-vn9e4rqj.js} +6 -2
  36. package/dist/{handlers-3hqw1dsr.js → handlers-xfja18ac.js} +8 -8
  37. package/dist/{hook-5981d59r.js → hook-2medrp47.js} +1 -1
  38. package/dist/{http-ek9ctgth.js → http-937pyjfc.js} +49 -39
  39. package/dist/{index-33eb3q08.js → index-2bqd2q9v.js} +11 -11
  40. package/dist/{index-8j9b86c9.js → index-gw4en7jg.js} +9 -9
  41. package/dist/{index-chcty242.js → index-knqczmnm.js} +10 -10
  42. package/dist/{index-1cnvzmh4.js → index-vah60vxp.js} +8 -8
  43. package/dist/{keepalive-rrvxy1fy.js → keepalive-20rd6hxh.js} +5 -5
  44. package/dist/{lead-jvw8b413.js → lead-7w2k3a49.js} +20 -20
  45. package/dist/{maintenance-3zjnr2wa.js → maintenance-p3x918yw.js} +4 -4
  46. package/dist/{oauth-refresh-sweep-vfhvqmby.js → oauth-refresh-sweep-shm0nzm4.js} +4 -4
  47. package/dist/{onboard-kvntqz8h.js → onboard-py9dnf54.js} +2 -2
  48. package/dist/{otel-impl-61tsn4bx.js → otel-impl-7yjkfg68.js} +1 -1
  49. package/dist/{pricing-refresh-jcp6ntbk.js → pricing-refresh-3snb6370.js} +4 -4
  50. package/dist/{rbac-roles-xcbab18x.js → rbac-roles-5e72bbpk.js} +3 -3
  51. package/dist/{rbac-roles-r3j4z9vs.js → rbac-roles-gqxdp8st.js} +4 -4
  52. package/dist/{seed-pricing-bd669a7j.js → seed-pricing-bg42d7h2.js} +3 -3
  53. package/dist/{setup-pg03xran.js → setup-nzk3kbbq.js} +2 -2
  54. package/dist/{worker-q5n4qwvq.js → worker-0p1jdzkm.js} +20 -20
  55. package/openapi.json +23 -1
  56. package/package.json +1 -1
  57. package/src/be/db.ts +81 -4
  58. package/src/be/scripts/typecheck.ts +8 -4
  59. package/src/be/seed-scripts/catalog/task-context-gathering.ts +1 -1
  60. package/src/http/workflows.ts +16 -4
  61. package/src/prompts/session-templates.ts +6 -3
  62. package/src/scripts-runtime/swarm-sdk.ts +10 -0
  63. package/src/scripts-runtime/types/stdlib.d.ts +4 -0
  64. package/src/scripts-runtime/types/swarm-sdk.d.ts +4 -0
  65. package/src/telemetry.ts +10 -7
  66. package/src/tests/mcp-tools.test.ts +2 -2
  67. package/src/tests/prompt-template-session.test.ts +7 -0
  68. package/src/tests/scripts-mcp-e2e.test.ts +51 -2
  69. package/src/tests/scripts-typecheck.test.ts +17 -0
  70. package/src/tests/sdk-allowlist.test.ts +34 -1
  71. package/src/tests/seed-scripts.test.ts +43 -0
  72. package/src/tests/swarm-tool-result-gate.test.ts +35 -0
  73. package/src/tests/task-tools-ctx.test.ts +29 -0
  74. package/src/tests/telemetry-init.test.ts +21 -24
  75. package/src/tests/workflow-http-v2.test.ts +116 -0
  76. package/src/tools/get-tasks.ts +40 -1
  77. package/src/tools/script-run.ts +2 -2
  78. package/src/tools/script-upsert.ts +2 -2
  79. package/src/tools/utils.ts +14 -8
  80. package/src/tools/workflows/list-workflow-runs.ts +95 -24
@@ -9,7 +9,7 @@ import {
9
9
  mcpToolNameForSdkMethod,
10
10
  SDK_ALLOWLIST,
11
11
  } from "../scripts-runtime/sdk-allowlist";
12
- import type { SwarmConfig } from "../scripts-runtime/swarm-config";
12
+ import { SwarmConfig } from "../scripts-runtime/swarm-config";
13
13
  import { createSwarmSdk } from "../scripts-runtime/swarm-sdk";
14
14
  import { createServer } from "../server";
15
15
 
@@ -65,6 +65,39 @@ describe("script SDK allowlist", () => {
65
65
  );
66
66
  });
67
67
 
68
+ test("workflow_listRuns sends bounded pagination defaults and rejects an excessive limit", async () => {
69
+ let requestUrl: URL | undefined;
70
+ const httpServer = Bun.serve({
71
+ port: 0,
72
+ fetch(req) {
73
+ requestUrl = new URL(req.url);
74
+ return Response.json({ runs: [], page: { limit: 20, offset: 0, total: 0 } });
75
+ },
76
+ });
77
+ const config = new SwarmConfig({
78
+ system: {
79
+ apiKey: { value: "sdk-test-key", isSecret: true },
80
+ agentId: { value: "sdk-test-agent", isSecret: false },
81
+ mcpBaseUrl: { value: `http://127.0.0.1:${httpServer.port}`, isSecret: false },
82
+ },
83
+ user: {},
84
+ });
85
+ const sdk = createSwarmSdk(config);
86
+
87
+ try {
88
+ await sdk.workflow_listRuns({ workflowId: "workflow-1" });
89
+ expect(requestUrl?.pathname).toBe("/api/workflows/workflow-1/runs");
90
+ expect(requestUrl?.searchParams.get("limit")).toBe("20");
91
+ expect(requestUrl?.searchParams.get("offset")).toBe("0");
92
+
93
+ await expect(sdk.workflow_listRuns({ workflowId: "workflow-1", limit: 101 })).rejects.toThrow(
94
+ "between 1 and 100",
95
+ );
96
+ } finally {
97
+ httpServer.stop(true);
98
+ }
99
+ });
100
+
68
101
  test("bundled swarm-sdk.d.ts exposes only allowlisted methods", async () => {
69
102
  const types = await Bun.file("src/scripts-runtime/types/swarm-sdk.d.ts").text();
70
103
  for (const name of SDK_ALLOWLIST) {
@@ -14,6 +14,7 @@ import compoundInsights from "../be/seed-scripts/catalog/compound-insights";
14
14
  import opsCatalogAudit, {
15
15
  renderPage as renderOpsCatalogAuditPage,
16
16
  } from "../be/seed-scripts/catalog/ops-catalog-audit";
17
+ import taskContextGathering from "../be/seed-scripts/catalog/task-context-gathering";
17
18
  import { extractScriptSignature } from "../scripts-runtime/extract-signature";
18
19
  import { validateScriptImports } from "../scripts-runtime/import-allowlist";
19
20
 
@@ -97,6 +98,48 @@ describe("seed-scripts catalog", () => {
97
98
  }
98
99
  });
99
100
 
101
+ test("task-context-gathering unwraps the direct REST task response", async () => {
102
+ const taskId = "task-context-test";
103
+ const result = await taskContextGathering(
104
+ { taskId, queries: ["task context"] },
105
+ {
106
+ swarm: {
107
+ async task_get(args: { taskId: string }) {
108
+ expect(args.taskId).toBe(taskId);
109
+ return {
110
+ success: true,
111
+ status: 200,
112
+ data: {
113
+ id: taskId,
114
+ status: "in_progress",
115
+ task: "Restore the flattened task response consumer",
116
+ dependsOn: ["parent-task"],
117
+ agentId: "agent-test",
118
+ },
119
+ };
120
+ },
121
+ async memory_search() {
122
+ return { success: true, status: 200, data: { results: [] } };
123
+ },
124
+ },
125
+ },
126
+ );
127
+
128
+ expect(result.task).toEqual({
129
+ id: taskId,
130
+ status: "in_progress",
131
+ description: "Restore the flattened task response consumer",
132
+ dependsOn: ["parent-task"],
133
+ slackChannelId: undefined,
134
+ slackThreadTs: undefined,
135
+ createdAt: undefined,
136
+ finishedAt: undefined,
137
+ agentId: "agent-test",
138
+ output: undefined,
139
+ failureReason: undefined,
140
+ });
141
+ });
142
+
100
143
  test("scriptsSeeder declares the script kind and one item per catalog entry", async () => {
101
144
  expect(scriptsSeeder.kind).toBe("script");
102
145
  const items = await scriptsSeeder.items();
@@ -214,6 +214,41 @@ describe("finalizeSwarmToolResult", () => {
214
214
  expect(text).toContain("[truncated");
215
215
  });
216
216
 
217
+ test("explicit details are normalized and capped identically in both channels", () => {
218
+ const blob = "x".repeat(50_000);
219
+ const result = finalizeSwarmToolResult("some-tool", {
220
+ ok: true,
221
+ message: "Big rendered payload.",
222
+ details: ` ${blob} `,
223
+ data: { blob },
224
+ });
225
+ const text = (result.content?.[0] as { text: string }).text;
226
+ const structured = result.structuredContent as {
227
+ details?: string;
228
+ blob?: string;
229
+ };
230
+
231
+ expect(structured.details).toContain("[truncated");
232
+ expect(structured.details!.length).toBeLessThan(10_000);
233
+ expect(text).toBe(`Big rendered payload.\n\n${structured.details}`);
234
+ // Only the rendered details are bounded; callers using structured data
235
+ // still receive the complete payload.
236
+ expect(structured.blob).toBe(blob);
237
+ });
238
+
239
+ test("whitespace-only details fall back to a rendered data preview", () => {
240
+ const result = finalizeSwarmToolResult("some-tool", {
241
+ ok: true,
242
+ message: "Saved.",
243
+ details: " \n\t ",
244
+ data: { taskId: "t-whitespace" },
245
+ });
246
+ const text = (result.content?.[0] as { text: string }).text;
247
+
248
+ expect(text).toContain('"taskId": "t-whitespace"');
249
+ expect((result.structuredContent as { details?: string }).details).toBeUndefined();
250
+ });
251
+
217
252
  test("an explicit tool-provided nudge wins over the central map", () => {
218
253
  const result = finalizeSwarmToolResult("script-run", {
219
254
  ok: false,
@@ -74,6 +74,35 @@ describe("task tool ctx", () => {
74
74
  expect(data.tasks.every((task) => task.task?.startsWith("owned task"))).toBe(true);
75
75
  });
76
76
 
77
+ test("getTasksHandler renders compact escaped markdown without changing structured tasks", async () => {
78
+ const user = createUser({ name: "Markdown List User" });
79
+ const taskText = "triage | path\\one\nsecond line";
80
+ const task = createTaskExtended(taskText, { requestedByUserId: user.id });
81
+
82
+ const result = await getTasksHandler(userCtx(user), {
83
+ includeFull: true,
84
+ includeHeartbeat: true,
85
+ limit: 50,
86
+ });
87
+
88
+ expect(result.details).toContain("| ID | Status | Priority | Agent | Task |");
89
+ expect(result.details).toContain("triage \\| path\\\\one<br>second line");
90
+ expect(result.details).not.toContain('"tasks":');
91
+ const data = result.data as { tasks: Array<{ id: string; task?: string }> };
92
+ expect(data.tasks.find((row) => row.id === task.id)?.task).toBe(taskText);
93
+ });
94
+
95
+ test("getTasksHandler uses a human empty-state instead of a raw data fallback", async () => {
96
+ const user = createUser({ name: "Empty List User" });
97
+ const result = await getTasksHandler(userCtx(user), {
98
+ includeHeartbeat: true,
99
+ limit: 50,
100
+ });
101
+
102
+ expect(result.details).toBe("No tasks matched the current filters.");
103
+ expect((result.data as { tasks: unknown[] }).tasks).toEqual([]);
104
+ });
105
+
77
106
  test("assertOwnsTask gates user tasks and allows owned or owner ctx", () => {
78
107
  const owner = createUser({ name: "Task Owner" });
79
108
  const foreignUser = createUser({ name: "Foreign User" });
@@ -122,44 +122,25 @@ describe("initTelemetry", () => {
122
122
  expect(_getInstalledAtForTests()).toBeNull();
123
123
  });
124
124
 
125
- test("backfill: telemetry_installed_at write failsexisting installationId is preserved, installedAt stays null", async () => {
126
- const existing = "install_backfillfailure";
125
+ test("existing installationId, no stored anchor installedAt omitted, no backfill write attempted (even with generateIfMissing)", async () => {
126
+ const existing = "install_noanchor";
127
127
  const writes: Array<{ key: string; value: string }> = [];
128
128
  await initTelemetry(
129
129
  "api-server",
130
130
  async (key) => (key === "telemetry_installation_id" ? existing : undefined),
131
131
  async (key, value) => {
132
- if (key === "telemetry_installed_at") {
133
- throw new Error("config write failed");
134
- }
135
132
  writes.push({ key, value });
136
133
  },
137
134
  { generateIfMissing: true },
138
135
  );
139
- // The pre-existing installation ID must survive the unrelated
140
- // installed_at write failure not get discarded for an ephemeral one.
136
+ // A pre-existing installation ID with no stored anchor must NOT mint
137
+ // now() as a stand-in install date that back-fills a wrong date
138
+ // instead of an honestly-absent one. No write, no fabricated anchor.
141
139
  expect(_getInstallationIdForTests()).toBe(existing);
142
140
  expect(writes).toEqual([]);
143
141
  expect(_getInstalledAtForTests()).toBeNull();
144
142
  });
145
143
 
146
- test("backfill: telemetry_installed_at write succeeds → mints and persists exactly once", async () => {
147
- const existing = "install_backfillsuccess";
148
- const writes: Array<{ key: string; value: string }> = [];
149
- await initTelemetry(
150
- "api-server",
151
- async (key) => (key === "telemetry_installation_id" ? existing : undefined),
152
- async (key, value) => {
153
- writes.push({ key, value });
154
- },
155
- { generateIfMissing: true },
156
- );
157
- expect(_getInstallationIdForTests()).toBe(existing);
158
- const installedAt = _getInstalledAtForTests();
159
- expect(installedAt).not.toBeNull();
160
- expect(writes).toEqual([{ key: "telemetry_installed_at", value: installedAt as string }]);
161
- });
162
-
163
144
  test("already-exists: both installation_id and installed_at present → no writes, reuses existing anchor", async () => {
164
145
  const existing = "install_alreadyexists";
165
146
  const existingInstalledAt = "2026-01-01T00:00:00.000Z";
@@ -847,6 +828,22 @@ describe("initTelemetry", () => {
847
828
  expect(typeof metadata.install_created_at).toBe("string");
848
829
  });
849
830
 
831
+ test("pre-existing installationId with no stored anchor → install_created_at is absent from the payload, not back-filled with now()", async () => {
832
+ await initTelemetry(
833
+ "api-server",
834
+ async (key) =>
835
+ key === "telemetry_installation_id" ? "install_existingnoanchor" : undefined,
836
+ async () => {},
837
+ { generateIfMissing: true },
838
+ );
839
+
840
+ track({ event: "server.started", properties: {} });
841
+ await new Promise((r) => setTimeout(r, 0));
842
+
843
+ const metadata = (captured as { metadata: Record<string, unknown> }).metadata;
844
+ expect(metadata.install_created_at).toBeUndefined();
845
+ });
846
+
850
847
  test("wizard install: INSTALL_METHOD + INSTALL_PRESET flow through to properties/metadata", async () => {
851
848
  process.env.INSTALL_METHOD = "onboard_noninteractive";
852
849
  process.env.INSTALL_PRESET = "solo";
@@ -11,12 +11,17 @@ import {
11
11
  closeDb,
12
12
  createWorkflowRun,
13
13
  createWorkflowRunStep,
14
+ getDb,
14
15
  getWorkflowVersions,
15
16
  initDb,
16
17
  updateWorkflowRun,
17
18
  } from "../be/db";
18
19
  import { getPathSegments, parseQueryParams } from "../http/utils";
19
20
  import { handleWorkflows } from "../http/workflows";
21
+ import {
22
+ listWorkflowRunsHandler,
23
+ listWorkflowRunsInputSchema,
24
+ } from "../tools/workflows/list-workflow-runs";
20
25
  import type {
21
26
  Workflow,
22
27
  WorkflowDefinition,
@@ -549,6 +554,117 @@ describe("Workflow HTTP API v2", () => {
549
554
  expect(run.status).toBe("waiting");
550
555
  }
551
556
  });
557
+
558
+ test("supports deterministic limit/offset pages while preserving the omitted legacy shape", async () => {
559
+ const workflow = await createTestWorkflow();
560
+ const runIds = Array.from({ length: 5 }, () => crypto.randomUUID());
561
+ const statuses = ["running", "failed", "running", "failed", "running"] as const;
562
+
563
+ for (const [index, id] of runIds.entries()) {
564
+ createWorkflowRun({ id, workflowId: workflow.id });
565
+ getDb().run("UPDATE workflow_runs SET status = ?, startedAt = ? WHERE id = ?", [
566
+ statuses[index]!,
567
+ new Date(Date.UTC(2026, 0, index + 1)).toISOString(),
568
+ id,
569
+ ]);
570
+ }
571
+
572
+ const legacyRes = await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs`, { headers });
573
+ expect(legacyRes.status).toBe(200);
574
+ const legacyBody = (await legacyRes.json()) as WorkflowRun[];
575
+ expect(Array.isArray(legacyBody)).toBe(true);
576
+ expect(legacyBody.map((run) => run.id)).toEqual([...runIds].reverse());
577
+
578
+ const pageRes = await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs?limit=2&offset=1`, {
579
+ headers,
580
+ });
581
+ expect(pageRes.status).toBe(200);
582
+ const pageBody = (await pageRes.json()) as {
583
+ runs: WorkflowRun[];
584
+ page: {
585
+ limit: number;
586
+ offset: number;
587
+ total: number;
588
+ hasMore: boolean;
589
+ nextOffset?: number;
590
+ };
591
+ };
592
+ expect(pageBody.runs.map((run) => run.id)).toEqual([runIds[3], runIds[2]]);
593
+ expect(pageBody.page).toEqual({
594
+ limit: 2,
595
+ offset: 1,
596
+ total: 5,
597
+ hasMore: true,
598
+ nextOffset: 3,
599
+ });
600
+
601
+ const filteredRes = await fetch(
602
+ `${baseUrl}/api/workflows/${workflow.id}/runs?status=failed&limit=1`,
603
+ { headers },
604
+ );
605
+ expect(filteredRes.status).toBe(200);
606
+ const filteredBody = (await filteredRes.json()) as {
607
+ runs: WorkflowRun[];
608
+ page: { total: number };
609
+ };
610
+ expect(filteredBody.runs.map((run) => run.id)).toEqual([runIds[3]]);
611
+ expect(filteredBody.page.total).toBe(2);
612
+
613
+ const invalidRes = await fetch(`${baseUrl}/api/workflows/${workflow.id}/runs?limit=101`, {
614
+ headers,
615
+ });
616
+ expect(invalidRes.status).toBe(400);
617
+ });
618
+
619
+ test("MCP run listing defaults to bounded slim rows and preserves an explicit full-row opt-in", async () => {
620
+ const workflow = await createTestWorkflow();
621
+ const largeValue = "x".repeat(100_000);
622
+ const run = createWorkflowRun({
623
+ id: crypto.randomUUID(),
624
+ workflowId: workflow.id,
625
+ triggerData: { largeValue },
626
+ });
627
+ updateWorkflowRun(run.id, { context: { nodeOutput: largeValue } });
628
+ const parsed = listWorkflowRunsInputSchema.parse({ workflowId: workflow.id });
629
+ expect(parsed.limit).toBe(20);
630
+ expect(parsed.offset).toBe(0);
631
+ expect(parsed.includeContext).toBe(false);
632
+ expect(() =>
633
+ listWorkflowRunsInputSchema.parse({ workflowId: workflow.id, limit: 101 }),
634
+ ).toThrow();
635
+
636
+ const result = listWorkflowRunsHandler(parsed);
637
+ expect(result.ok).toBe(true);
638
+ const data = result.data as {
639
+ runs: Array<{
640
+ id: string;
641
+ context?: unknown;
642
+ triggerData?: unknown;
643
+ triggerDataSummary?: string;
644
+ }>;
645
+ page: { limit: number; offset: number; total: number; hasMore: boolean };
646
+ };
647
+ expect(data.runs.map((row) => row.id)).toEqual([run.id]);
648
+ expect(data.runs[0]!.context).toBeUndefined();
649
+ expect(data.runs[0]!.triggerData).toBeUndefined();
650
+ expect(data.runs[0]!.triggerDataSummary).toEndWith("…");
651
+ expect(data.runs[0]!.triggerDataSummary!.length).toBe(401);
652
+ expect(data.page).toEqual({
653
+ limit: 20,
654
+ offset: 0,
655
+ total: 1,
656
+ hasMore: false,
657
+ });
658
+ expect(JSON.stringify(result).length).toBeLessThan(20_000);
659
+
660
+ const fullResult = listWorkflowRunsHandler({
661
+ ...parsed,
662
+ includeContext: true,
663
+ });
664
+ const fullData = fullResult.data as { runs: WorkflowRun[] };
665
+ expect(fullData.runs[0]!.context).toEqual({ nodeOutput: largeValue });
666
+ expect(fullData.runs[0]!.triggerData).toEqual({ largeValue });
667
+ });
552
668
  });
553
669
 
554
670
  // ─── GET RUN DETAIL ────────────────────────────────────────
@@ -79,6 +79,45 @@ export const getTasksOutputSchema = swarmToolOutputSchema({
79
79
 
80
80
  type GetTasksArgs = z.infer<typeof getTasksInputSchema>;
81
81
 
82
+ const TASK_DETAILS_CELL_CAP = 180;
83
+
84
+ function escapeMarkdownCell(value: unknown): string {
85
+ const text = String(value ?? "—");
86
+ const compact =
87
+ text.length > TASK_DETAILS_CELL_CAP ? `${text.slice(0, TASK_DETAILS_CELL_CAP - 1)}…` : text;
88
+ return compact
89
+ .replaceAll("\\", "\\\\")
90
+ .replaceAll("|", "\\|")
91
+ .replace(/\r\n|\r|\n/g, "<br>");
92
+ }
93
+
94
+ function renderTaskSummaries(
95
+ tasks: Array<{
96
+ id?: string;
97
+ agentId?: string | null;
98
+ task?: string;
99
+ taskPreview?: string;
100
+ status?: string;
101
+ priority?: number;
102
+ }>,
103
+ ): string {
104
+ if (tasks.length === 0) return "No tasks matched the current filters.";
105
+
106
+ const header = "| ID | Status | Priority | Agent | Task |";
107
+ const separator = "| --- | --- | ---: | --- | --- |";
108
+ const rows = tasks.map((task) => {
109
+ const cells = [
110
+ task.id,
111
+ task.status,
112
+ task.priority,
113
+ task.agentId,
114
+ task.taskPreview ?? task.task,
115
+ ].map(escapeMarkdownCell);
116
+ return `| ${cells.join(" | ")} |`;
117
+ });
118
+ return [header, separator, ...rows].join("\n");
119
+ }
120
+
82
121
  export async function getTasksHandler(
83
122
  ctx: ToolCtx,
84
123
  {
@@ -163,7 +202,7 @@ export async function getTasksHandler(
163
202
  };
164
203
 
165
204
  return toolOk(`Found ${taskSummaries.length} task(s)${filterMsg}.`, {
166
- details: JSON.stringify(data),
205
+ details: renderTaskSummaries(taskSummaries),
167
206
  data,
168
207
  });
169
208
  }
@@ -10,7 +10,7 @@ import {
10
10
  } from "./script-common";
11
11
 
12
12
  export const SCRIPT_RUN_DESCRIPTION =
13
- "Run a named swarm-shared script (callable across agents and from workflow `swarm-script` nodes), OR inline source (auto-saved as scratch to the catalog). Use for swarm-visible, durable scripts. For local-only throwaway TS, use code-mode `run`.";
13
+ "Run a named swarm-shared script (callable across agents and from workflow `swarm-script` nodes), OR inline source (auto-saved as scratch to the catalog). Inline source executes without the `script-upsert` compile-time typecheck. Use for swarm-visible, durable scripts. For local-only throwaway TS, use code-mode `run`.";
14
14
 
15
15
  function renderRunOutput(data: unknown): string | undefined {
16
16
  if (typeof data !== "object" || data === null) return undefined;
@@ -58,7 +58,7 @@ export const registerScriptRunTool = (server: McpServer) => {
58
58
  .min(1)
59
59
  .optional()
60
60
  .describe(
61
- "Inline TypeScript source to run. Must `export default async function (args, ctx)` — args FIRST, ctx second.",
61
+ 'Inline TypeScript source to run without a compile-time typecheck. Must `export default async function (args, ctx)` — args FIRST, ctx second. Import `ScriptContext` from "swarm-sdk" for promotion-safe typing.',
62
62
  ),
63
63
  args: z.unknown().optional().describe("JSON-serializable script arguments."),
64
64
  intent: z.string().default("").describe("Why this script is being run."),
@@ -10,7 +10,7 @@ import {
10
10
  } from "./script-common";
11
11
 
12
12
  export const SCRIPT_UPSERT_DESCRIPTION =
13
- "Persist a TypeScript script to the swarm catalog under your agent scope (or global if you're a lead). Other agents and workflow nodes will be able to find and run it. For local-only scripts, use code-mode `save`.";
13
+ 'Typecheck and persist a TypeScript script to the swarm catalog under your agent scope (or global if you\'re a lead). Import `ScriptContext` from "swarm-sdk" for a real context type. Other agents and workflow nodes will be able to find and run it. For local-only scripts, use code-mode `save`.';
14
14
 
15
15
  export const registerScriptUpsertTool = (server: McpServer) => {
16
16
  createToolRegistrar(server)(
@@ -25,7 +25,7 @@ export const registerScriptUpsertTool = (server: McpServer) => {
25
25
  .string()
26
26
  .min(1)
27
27
  .describe(
28
- "TypeScript source. Must `export default async function (args, ctx)` — args FIRST, ctx second.",
28
+ 'TypeScript source, typechecked before saving. Must `export default async function (args, ctx)` — args FIRST, ctx second. Import `ScriptContext` from "swarm-sdk" to type `ctx`.',
29
29
  ),
30
30
  description: z.string().default("").describe("Human-readable script description."),
31
31
  intent: z.string().default("").describe("Why this script exists."),
@@ -189,13 +189,14 @@ export const NUDGES: Record<string, (result: SwarmToolResult) => string | undefi
189
189
  },
190
190
  };
191
191
 
192
- // Cap for the auto-rendered data fallback in the text channel Codex's
193
- // ~10KB middle-out truncation is the tightest harness budget.
194
- const DATA_FALLBACK_CAP = 8_000;
192
+ // Cap for any rendered details string Codex's ~10KB middle-out truncation is
193
+ // the tightest harness budget. Structured data remains intact; only the
194
+ // human/model-facing rendering is bounded.
195
+ const DETAILS_CAP = 8_000;
195
196
 
196
197
  function truncateForText(text: string): string {
197
- if (text.length <= DATA_FALLBACK_CAP) return text;
198
- return `${text.slice(0, DATA_FALLBACK_CAP)}\n… [truncated ${text.length - DATA_FALLBACK_CAP} chars]`;
198
+ if (text.length <= DETAILS_CAP) return text;
199
+ return `${text.slice(0, DETAILS_CAP)}\n… [truncated ${text.length - DETAILS_CAP} chars]`;
199
200
  }
200
201
 
201
202
  type FinalizeContext = { toolName: string };
@@ -235,17 +236,22 @@ export function finalizeSwarmToolResult(toolName: string, result: SwarmToolResul
235
236
  }
236
237
  for (const middleware of FINALIZE_PIPELINE) r = middleware(r, { toolName });
237
238
 
239
+ // Normalize and cap explicit details only after the scrub middleware has
240
+ // removed secrets. The same bounded value feeds both wire channels.
241
+ // Whitespace-only details count as absent so data fallback remains visible.
242
+ const explicitDetails = r.details?.trim() ? truncateForText(r.details.trim()) : undefined;
243
+
238
244
  // Text-channel completeness guarantee: when a tool sets data but no details,
239
245
  // render the data as JSON into the text channel. Most harnesses only ever
240
246
  // show the model content.text — without this fallback, a data-only payload
241
247
  // would be invisible there. Not copied into structuredContent.details (the
242
248
  // structured channel already carries data verbatim).
243
249
  const dataFallback =
244
- !r.details?.trim() && r.data && Object.keys(r.data).length > 0
250
+ !explicitDetails && r.data && Object.keys(r.data).length > 0
245
251
  ? truncateForText(JSON.stringify(r.data, null, 2))
246
252
  : undefined;
247
253
 
248
- const text = [r.message, r.details ?? dataFallback, r.nudge]
254
+ const text = [r.message, explicitDetails ?? dataFallback, r.nudge]
249
255
  .filter((part): part is string => Boolean(part?.trim()))
250
256
  .join("\n\n");
251
257
  const structuredContent: Record<string, unknown> = {
@@ -253,7 +259,7 @@ export function finalizeSwarmToolResult(toolName: string, result: SwarmToolResul
253
259
  success: r.ok,
254
260
  message: r.message,
255
261
  };
256
- if (r.details) structuredContent.details = r.details;
262
+ if (explicitDetails) structuredContent.details = explicitDetails;
257
263
  if (r.nudge) structuredContent.nudge = r.nudge;
258
264
 
259
265
  return {