@desplega.ai/agent-swarm 1.80.0 → 1.80.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 (100) hide show
  1. package/openapi.json +399 -14
  2. package/package.json +3 -1
  3. package/src/artifact-sdk/server.ts +2 -1
  4. package/src/be/db.ts +1 -1
  5. package/src/be/migrations/064_scripts.sql +39 -0
  6. package/src/be/migrations/065_script_embeddings.sql +7 -0
  7. package/src/be/migrations/066_scripts_args_json_schema.sql +1 -0
  8. package/src/be/scripts/db.ts +417 -0
  9. package/src/be/scripts/embeddings.ts +233 -0
  10. package/src/be/scripts/extract-schema.ts +55 -0
  11. package/src/be/scripts/maintenance.ts +9 -0
  12. package/src/be/scripts/typecheck.ts +199 -0
  13. package/src/cli.tsx +22 -5
  14. package/src/commands/artifact.ts +3 -2
  15. package/src/commands/claude-managed-setup.ts +2 -1
  16. package/src/commands/codex-login.ts +5 -3
  17. package/src/commands/onboard.tsx +2 -1
  18. package/src/commands/runner.ts +153 -20
  19. package/src/commands/setup.tsx +5 -3
  20. package/src/hooks/hook.ts +4 -3
  21. package/src/http/index.ts +40 -29
  22. package/src/http/memory.ts +28 -0
  23. package/src/http/openapi.ts +1 -0
  24. package/src/http/page-proxy.ts +2 -1
  25. package/src/http/route-def.ts +1 -0
  26. package/src/http/schedules.ts +37 -0
  27. package/src/http/scripts.ts +388 -0
  28. package/src/linear/outbound.ts +9 -2
  29. package/src/otel.ts +5 -0
  30. package/src/providers/claude-adapter.ts +23 -1
  31. package/src/providers/types.ts +8 -0
  32. package/src/scripts-runtime/ctx.ts +23 -0
  33. package/src/scripts-runtime/eval-harness.ts +63 -0
  34. package/src/scripts-runtime/executors/native.ts +232 -0
  35. package/src/scripts-runtime/executors/registry.ts +16 -0
  36. package/src/scripts-runtime/executors/types.ts +63 -0
  37. package/src/scripts-runtime/extract-args-schema.ts +69 -0
  38. package/src/scripts-runtime/extract-signature.ts +81 -0
  39. package/src/scripts-runtime/import-allowlist.ts +109 -0
  40. package/src/scripts-runtime/loader.ts +96 -0
  41. package/src/scripts-runtime/redacted.ts +48 -0
  42. package/src/scripts-runtime/sdk-allowlist.ts +29 -0
  43. package/src/scripts-runtime/stdlib/fetch.ts +46 -0
  44. package/src/scripts-runtime/stdlib/glob.ts +8 -0
  45. package/src/scripts-runtime/stdlib/grep.ts +34 -0
  46. package/src/scripts-runtime/stdlib/index.ts +16 -0
  47. package/src/scripts-runtime/stdlib/table.ts +17 -0
  48. package/src/scripts-runtime/swarm-config.ts +35 -0
  49. package/src/scripts-runtime/swarm-sdk.ts +197 -0
  50. package/src/scripts-runtime/types/stdlib.d.ts +104 -0
  51. package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
  52. package/src/server.ts +12 -0
  53. package/src/tests/api-key.test.ts +33 -0
  54. package/src/tests/codex-login.test.ts +1 -1
  55. package/src/tests/error-tracker.test.ts +44 -0
  56. package/src/tests/linear-outbound-sync.test.ts +109 -0
  57. package/src/tests/mcp-tools.test.ts +69 -0
  58. package/src/tests/rate-limit-event.test.ts +292 -0
  59. package/src/tests/redacted.test.ts +29 -0
  60. package/src/tests/runner-tool-spans.test.ts +268 -0
  61. package/src/tests/script-executor-conformance.test.ts +142 -0
  62. package/src/tests/script-executor-registry.test.ts +17 -0
  63. package/src/tests/scripts-db.test.ts +329 -0
  64. package/src/tests/scripts-embeddings.test.ts +291 -0
  65. package/src/tests/scripts-extract-signature.test.ts +47 -0
  66. package/src/tests/scripts-http.test.ts +403 -0
  67. package/src/tests/scripts-import-allowlist.test.ts +55 -0
  68. package/src/tests/scripts-mcp-e2e.test.ts +269 -0
  69. package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
  70. package/src/tests/scripts-runtime.test.ts +344 -0
  71. package/src/tests/sdk-allowlist.test.ts +59 -0
  72. package/src/tests/secret-scrubber.test.ts +35 -1
  73. package/src/tests/swarm-config.test.ts +38 -0
  74. package/src/tests/tool-annotations.test.ts +2 -2
  75. package/src/tests/tool-call-progress.test.ts +30 -0
  76. package/src/tests/workflow-e2e.test.ts +218 -0
  77. package/src/tests/workflow-executors.test.ts +32 -2
  78. package/src/tests/workflow-input-redaction.test.ts +232 -0
  79. package/src/tests/workflow-swarm-script.test.ts +273 -0
  80. package/src/tools/memory-rate.ts +2 -1
  81. package/src/tools/script-common.ts +88 -0
  82. package/src/tools/script-delete.ts +35 -0
  83. package/src/tools/script-query-types.ts +37 -0
  84. package/src/tools/script-run.ts +43 -0
  85. package/src/tools/script-search.ts +32 -0
  86. package/src/tools/script-upsert.ts +43 -0
  87. package/src/tools/tool-config.ts +7 -0
  88. package/src/types.ts +61 -1
  89. package/src/utils/api-key.ts +28 -0
  90. package/src/utils/error-tracker.ts +58 -0
  91. package/src/utils/page-session.ts +8 -6
  92. package/src/utils/secret-scrubber.ts +22 -1
  93. package/src/workflows/engine.ts +12 -4
  94. package/src/workflows/executors/index.ts +1 -0
  95. package/src/workflows/executors/registry.ts +2 -0
  96. package/src/workflows/executors/script.ts +12 -1
  97. package/src/workflows/executors/swarm-script.ts +170 -0
  98. package/src/workflows/input.ts +65 -0
  99. package/src/workflows/recovery.ts +31 -3
  100. package/src/workflows/resume.ts +43 -5
@@ -0,0 +1,59 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, initDb } from "../be/db";
4
+ import { mcpToolNameForSdkMethod, SDK_ALLOWLIST } from "../scripts-runtime/sdk-allowlist";
5
+ import type { SwarmConfig } from "../scripts-runtime/swarm-config";
6
+ import { createSwarmSdk } from "../scripts-runtime/swarm-sdk";
7
+ import { createServer } from "../server";
8
+
9
+ const TEST_DB_PATH = "./test-sdk-allowlist.sqlite";
10
+
11
+ async function removeDbFiles(path: string): Promise<void> {
12
+ for (const suffix of ["", "-wal", "-shm"]) {
13
+ try {
14
+ await unlink(path + suffix);
15
+ } catch (error) {
16
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
17
+ }
18
+ }
19
+ }
20
+
21
+ describe("script SDK allowlist", () => {
22
+ let registeredTools: Record<string, unknown>;
23
+
24
+ beforeAll(async () => {
25
+ await removeDbFiles(TEST_DB_PATH);
26
+ initDb(TEST_DB_PATH);
27
+ const server = createServer();
28
+ registeredTools = (server as unknown as { _registeredTools: Record<string, unknown> })
29
+ ._registeredTools;
30
+ });
31
+
32
+ afterAll(async () => {
33
+ closeDb();
34
+ await removeDbFiles(TEST_DB_PATH);
35
+ });
36
+
37
+ test("every SDK allowlist entry resolves to a live MCP tool", () => {
38
+ const missing = SDK_ALLOWLIST.map((name) => mcpToolNameForSdkMethod(name)).filter(
39
+ (name) => !(name in registeredTools),
40
+ );
41
+ expect(missing).toEqual([]);
42
+ });
43
+
44
+ test("runtime proxy rejects non-allowlisted tools before fetch", async () => {
45
+ const sdk = createSwarmSdk({} as SwarmConfig);
46
+ await expect(sdk.join_swarm({})).rejects.toThrow(
47
+ "Tool 'join_swarm' is not exposed to scripts (lifecycle/cred tool)",
48
+ );
49
+ });
50
+
51
+ test("bundled swarm-sdk.d.ts exposes only allowlisted methods", async () => {
52
+ const types = await Bun.file("src/scripts-runtime/types/swarm-sdk.d.ts").text();
53
+ for (const name of SDK_ALLOWLIST) {
54
+ expect(types).toContain(`${name}(args`);
55
+ }
56
+ expect(types).not.toContain("join_swarm(");
57
+ expect(types).not.toContain("start_worker(");
58
+ });
59
+ });
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { refreshSecretScrubberCache, scrubSecrets } from "../utils/secret-scrubber";
2
+ import { refreshSecretScrubberCache, scrubObject, scrubSecrets } from "../utils/secret-scrubber";
3
3
 
4
4
  // Snapshot/restore process.env between tests so env-derived cache entries
5
5
  // don't leak across cases.
@@ -266,3 +266,37 @@ describe("scrubSecrets — does not over-scrub", () => {
266
266
  expect(out).toBe("example: ghp_TOKEN and glpat-xyz (both too short)");
267
267
  });
268
268
  });
269
+
270
+ describe("scrubObject", () => {
271
+ test("scrubs nested object and array string leaves", () => {
272
+ process.env.NESTED_TOKEN = "nested-secret-value-1234567890";
273
+ refreshSecretScrubberCache();
274
+
275
+ const out = scrubObject({
276
+ keep: 1,
277
+ nested: {
278
+ secret: "nested-secret-value-1234567890",
279
+ list: ["safe", "nested-secret-value-1234567890"],
280
+ },
281
+ nullish: null,
282
+ bool: true,
283
+ });
284
+
285
+ expect(out).toEqual({
286
+ keep: 1,
287
+ nested: {
288
+ secret: "[REDACTED:NESTED_TOKEN]",
289
+ list: ["safe", "[REDACTED:NESTED_TOKEN]"],
290
+ },
291
+ nullish: null,
292
+ bool: true,
293
+ });
294
+ });
295
+
296
+ test("handles circular references without recursing forever", () => {
297
+ const value: Record<string, unknown> = { a: "ok" };
298
+ value.self = value;
299
+
300
+ expect(scrubObject(value)).toEqual({ a: "ok", self: "[Circular]" });
301
+ });
302
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SwarmConfigPayload } from "../scripts-runtime/executors/types";
3
+ import { Redacted } from "../scripts-runtime/redacted";
4
+ import { SwarmConfig } from "../scripts-runtime/swarm-config";
5
+
6
+ const payload: SwarmConfigPayload = {
7
+ system: {
8
+ apiKey: { value: "test-api-key", isSecret: true },
9
+ agentId: { value: "agent-1", isSecret: false },
10
+ mcpBaseUrl: { value: "http://localhost:3013", isSecret: false },
11
+ },
12
+ user: {
13
+ "user-key": { value: "user-value", isSecret: true },
14
+ },
15
+ };
16
+
17
+ describe("SwarmConfig", () => {
18
+ test("hydrates system values as Redacted values with metadata", () => {
19
+ const config = new SwarmConfig(payload);
20
+ expect(Redacted.value(config.apiKey)).toBe("test-api-key");
21
+ expect(Redacted.meta(config.apiKey)).toEqual({ type: "system", isSecret: true });
22
+ expect(Redacted.value(config.agentId)).toBe("agent-1");
23
+ expect(Redacted.meta(config.mcpBaseUrl)).toEqual({ type: "system", isSecret: false });
24
+ });
25
+
26
+ test("returns user-set config values", () => {
27
+ const config = new SwarmConfig(payload);
28
+ const value = config.get("user-key");
29
+ expect(value).toBeDefined();
30
+ expect(Redacted.value(value!)).toBe("user-value");
31
+ expect(Redacted.meta(value!)).toEqual({ type: "user", isSecret: true });
32
+ });
33
+
34
+ test("missing user keys return undefined", () => {
35
+ const config = new SwarmConfig(payload);
36
+ expect(config.get("missing")).toBeUndefined();
37
+ });
38
+ });
@@ -323,9 +323,9 @@ describe("Tool Annotations & Classification", () => {
323
323
  test("registered tool count matches expected total", () => {
324
324
  const count = Object.keys(tools).length;
325
325
  // We expect all tools to be registered when all capabilities are enabled (default)
326
- // Includes 11 skill tools and 7 MCP server tools
326
+ // Includes 11 skill tools, 7 MCP server tools, and reusable script tools
327
327
  expect(count).toBeGreaterThanOrEqual(45);
328
- expect(count).toBeLessThanOrEqual(95);
328
+ expect(count).toBeLessThanOrEqual(100);
329
329
  });
330
330
 
331
331
  test("core tools are fewer than deferred tools", () => {
@@ -151,6 +151,31 @@ describe("toolCallToProgress", () => {
151
151
  expect(result).toBe("💬 Posting to Slack");
152
152
  });
153
153
 
154
+ test("agent-swarm:script-search has pretty label", () => {
155
+ const result = toolCallToProgress("mcp__agent-swarm__script-search", {});
156
+ expect(result).toBe("📜 Searching scripts");
157
+ });
158
+
159
+ test("agent-swarm:script-run has pretty label", () => {
160
+ const result = toolCallToProgress("mcp__agent-swarm__script-run", {});
161
+ expect(result).toBe("📜 Running script");
162
+ });
163
+
164
+ test("agent-swarm:script-upsert has pretty label", () => {
165
+ const result = toolCallToProgress("mcp__agent-swarm__script-upsert", {});
166
+ expect(result).toBe("📜 Saving script");
167
+ });
168
+
169
+ test("agent-swarm:script-delete has pretty label", () => {
170
+ const result = toolCallToProgress("mcp__agent-swarm__script-delete", {});
171
+ expect(result).toBe("📜 Deleting script");
172
+ });
173
+
174
+ test("agent-swarm:script-query-types has pretty label", () => {
175
+ const result = toolCallToProgress("mcp__agent-swarm__script-query-types", {});
176
+ expect(result).toBe("📜 Reading script types");
177
+ });
178
+
154
179
  // --- Agent-swarm MCP tool NOT in lookup (humanized fallback) ---
155
180
 
156
181
  test("unknown agent-swarm tool gets humanized fallback", () => {
@@ -191,6 +216,11 @@ describe("toolCallToProgress", () => {
191
216
  expect(result).toBe("🗃️ Querying database");
192
217
  });
193
218
 
219
+ test("bare agent-swarm script-run has pretty label", () => {
220
+ const result = toolCallToProgress("script-run", {});
221
+ expect(result).toBe("📜 Running script");
222
+ });
223
+
194
224
  test("codex MCP args with agent-swarm server use pretty labels", () => {
195
225
  const result = toolCallToProgress("send-task", {
196
226
  server: "agent-swarm",
@@ -0,0 +1,218 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ closeDb,
5
+ createAgent,
6
+ createWorkflow,
7
+ getDb,
8
+ getTaskByWorkflowRunStepId,
9
+ getWorkflowRun,
10
+ getWorkflowRunStepsByRunId,
11
+ initDb,
12
+ } from "../be/db";
13
+ import { upsertScriptByName } from "../be/scripts/db";
14
+ import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
15
+ import type { Workflow, WorkflowDefinition } from "../types";
16
+ import { startWorkflowExecution } from "../workflows/engine";
17
+ import { InProcessEventBus } from "../workflows/event-bus";
18
+ import { AgentTaskExecutor } from "../workflows/executors/agent-task";
19
+ import type { ExecutorDependencies } from "../workflows/executors/base";
20
+ import { ExecutorRegistry } from "../workflows/executors/registry";
21
+ import { SwarmScriptExecutor } from "../workflows/executors/swarm-script";
22
+ import { setupWorkflowResumeListener } from "../workflows/resume";
23
+ import { interpolate } from "../workflows/template";
24
+
25
+ const TEST_DB_PATH = "./test-workflow-e2e.sqlite";
26
+ const API_KEY = "test-workflow-e2e-key-1234567890";
27
+
28
+ const noOpEmbeddingProvider = {
29
+ name: "test/noop-workflow-e2e-embedding",
30
+ dimensions: 1,
31
+ async embed() {
32
+ return null;
33
+ },
34
+ async embedBatch(texts: string[]) {
35
+ return texts.map(() => null);
36
+ },
37
+ };
38
+
39
+ const signatureJson = JSON.stringify({
40
+ args: { type: "object" },
41
+ result: { type: "object" },
42
+ });
43
+
44
+ let savedEnv: NodeJS.ProcessEnv;
45
+ let agentId: string;
46
+ let eventBus: InProcessEventBus;
47
+ let registry: ExecutorRegistry;
48
+
49
+ async function removeDbFiles(): Promise<void> {
50
+ for (const suffix of ["", "-wal", "-shm"]) {
51
+ try {
52
+ await unlink(TEST_DB_PATH + suffix);
53
+ } catch (error) {
54
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
55
+ }
56
+ }
57
+ }
58
+
59
+ function makeWorkflow(def: WorkflowDefinition): Workflow {
60
+ return createWorkflow({
61
+ name: `workflow-e2e-${crypto.randomUUID()}`,
62
+ definition: def,
63
+ createdByAgentId: agentId,
64
+ });
65
+ }
66
+
67
+ async function saveScript(name: string, source: string) {
68
+ return upsertScriptByName({
69
+ name,
70
+ scope: "agent",
71
+ scopeId: agentId,
72
+ source,
73
+ description: `${name} e2e script`,
74
+ intent: "workflow swarm-script e2e fixture",
75
+ signatureJson,
76
+ agentId,
77
+ typeChecked: true,
78
+ });
79
+ }
80
+
81
+ async function waitForRunStatus(runId: string, status: string): Promise<void> {
82
+ const deadline = Date.now() + 5_000;
83
+ while (Date.now() < deadline) {
84
+ if (getWorkflowRun(runId)?.status === status) return;
85
+ await new Promise((resolve) => setTimeout(resolve, 25));
86
+ }
87
+ throw new Error(`Timed out waiting for workflow run ${runId} to reach ${status}`);
88
+ }
89
+
90
+ beforeAll(async () => {
91
+ savedEnv = { ...process.env };
92
+ await removeDbFiles();
93
+ initDb(TEST_DB_PATH);
94
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
95
+ delete process.env.API_KEY;
96
+ setScriptEmbeddingProviderForTests(noOpEmbeddingProvider);
97
+
98
+ const agent = createAgent({ name: "workflow-e2e-agent", isLead: true, status: "idle" });
99
+ agentId = agent.id;
100
+
101
+ eventBus = new InProcessEventBus();
102
+ const db = await import("../be/db");
103
+ const deps: ExecutorDependencies = {
104
+ db,
105
+ eventBus,
106
+ interpolate: (template, ctx) => interpolate(template, ctx).result,
107
+ };
108
+ registry = new ExecutorRegistry();
109
+ registry.register(new SwarmScriptExecutor(deps));
110
+ registry.register(new AgentTaskExecutor(deps));
111
+ setupWorkflowResumeListener(eventBus, registry);
112
+ });
113
+
114
+ afterAll(async () => {
115
+ setScriptEmbeddingProviderForTests(null);
116
+ closeDb();
117
+ await removeDbFiles();
118
+ for (const key of Object.keys(process.env)) {
119
+ if (!(key in savedEnv)) delete process.env[key];
120
+ }
121
+ for (const [key, value] of Object.entries(savedEnv)) {
122
+ if (value === undefined) delete process.env[key];
123
+ else process.env[key] = value;
124
+ }
125
+ });
126
+
127
+ beforeEach(() => {
128
+ getDb().run("DELETE FROM workflow_run_steps");
129
+ getDb().run("DELETE FROM workflow_runs");
130
+ getDb().run("DELETE FROM scripts");
131
+ getDb().run("DELETE FROM agent_tasks");
132
+ getDb().run("DELETE FROM workflows");
133
+ });
134
+
135
+ describe("workflow e2e swarm-script", () => {
136
+ test("swarm-script full workflow run executes through the engine", async () => {
137
+ await saveScript(
138
+ "square",
139
+ `export default async (args: { value: number }) => ({ squared: args.value * args.value });`,
140
+ );
141
+ const workflow = makeWorkflow({
142
+ nodes: [
143
+ {
144
+ id: "script",
145
+ type: "swarm-script",
146
+ config: { scriptName: "square", args: { value: 4 } },
147
+ },
148
+ ],
149
+ });
150
+
151
+ const runId = await startWorkflowExecution(workflow, {}, registry);
152
+ const run = getWorkflowRun(runId);
153
+ const steps = getWorkflowRunStepsByRunId(runId);
154
+
155
+ expect(run?.status).toBe("completed");
156
+ expect(steps).toHaveLength(1);
157
+ expect(steps[0]?.output).toMatchObject({ result: { squared: 16 } });
158
+ });
159
+
160
+ test("swarm-script agent-task interleave", async () => {
161
+ await saveScript(
162
+ "first-script",
163
+ `export default async (args: { value: number }) => ({ value: args.value + 1 });`,
164
+ );
165
+ await saveScript(
166
+ "second-script",
167
+ `export default async (args: { value: string }) => ({ final: Number(args.value) + 1 });`,
168
+ );
169
+ const workflow = makeWorkflow({
170
+ nodes: [
171
+ {
172
+ id: "first",
173
+ type: "swarm-script",
174
+ config: { scriptName: "first-script", args: { value: 1 } },
175
+ next: "task",
176
+ },
177
+ {
178
+ id: "task",
179
+ type: "agent-task",
180
+ inputs: { first: "first.result.value" },
181
+ config: { template: "Use {{first}}" },
182
+ next: "second",
183
+ },
184
+ {
185
+ id: "second",
186
+ type: "swarm-script",
187
+ inputs: { taskValue: "task.taskOutput.value" },
188
+ config: { scriptName: "second-script", args: { value: "{{taskValue}}" } },
189
+ },
190
+ ],
191
+ });
192
+
193
+ const runId = await startWorkflowExecution(workflow, {}, registry);
194
+ expect(getWorkflowRun(runId)?.status).toBe("waiting");
195
+
196
+ const waitingSteps = getWorkflowRunStepsByRunId(runId);
197
+ const taskStep = waitingSteps.find((step) => step.nodeId === "task");
198
+ expect(taskStep?.status).toBe("waiting");
199
+ const task = getTaskByWorkflowRunStepId(taskStep!.id);
200
+ expect(task?.task).toBe("Use 2");
201
+
202
+ eventBus.emit("task.completed", {
203
+ taskId: task!.id,
204
+ output: JSON.stringify({ value: 41 }),
205
+ workflowRunId: runId,
206
+ workflowRunStepId: taskStep!.id,
207
+ });
208
+
209
+ await waitForRunStatus(runId, "completed");
210
+ const completedSteps = getWorkflowRunStepsByRunId(runId);
211
+ expect(completedSteps).toHaveLength(3);
212
+ expect(completedSteps.find((step) => step.nodeId === "first")?.status).toBe("completed");
213
+ expect(completedSteps.find((step) => step.nodeId === "task")?.status).toBe("completed");
214
+ expect(completedSteps.find((step) => step.nodeId === "second")?.output).toMatchObject({
215
+ result: { final: 42 },
216
+ });
217
+ });
218
+ });
@@ -516,6 +516,34 @@ describe("ScriptExecutor", () => {
516
516
  const valid = ScriptOutputSchema.safeParse({ exitCode: 0, stdout: "hi", stderr: "" });
517
517
  expect(valid.success).toBe(true);
518
518
  });
519
+
520
+ test("keeps raw {exitCode, stdout, stderr} when stdout is not valid JSON", async () => {
521
+ const result = await executor.run(
522
+ input({ runtime: "bash", script: "echo 'not-json {at all'" }, {}),
523
+ );
524
+ expect(result.status).toBe("success");
525
+ const out = result.output as { exitCode: number; stdout: string; stderr: string } & {
526
+ parsed?: unknown;
527
+ };
528
+ expect(out.exitCode).toBe(0);
529
+ expect(out.stdout).toBe("not-json {at all");
530
+ expect(out.stderr).toBe("");
531
+ // No parsed key merged in — only the raw three fields are present.
532
+ expect(Object.keys(out).sort()).toEqual(["exitCode", "stderr", "stdout"]);
533
+ });
534
+
535
+ test("populates structured output on timeout instead of leaving it null", async () => {
536
+ const result = await executor.run(
537
+ input({ runtime: "bash", script: "sleep 5", timeout: 1000 }, {}),
538
+ );
539
+ expect(result.status).toBe("failed");
540
+ expect(result.error).toContain("Script timed out after 1000ms");
541
+ const out = result.output as { exitCode: number; stdout: string; stderr: string };
542
+ expect(out).toBeDefined();
543
+ expect(out.exitCode).toBe(-1);
544
+ expect(out.stdout).toBe("");
545
+ expect(out.stderr).toContain("Script timed out after 1000ms");
546
+ });
519
547
  });
520
548
 
521
549
  // ─── VCS Executor ────────────────────────────────────────────
@@ -706,7 +734,7 @@ describe("ValidateExecutor", () => {
706
734
  // ─── Registry Wiring ─────────────────────────────────────────
707
735
 
708
736
  describe("createExecutorRegistry", () => {
709
- test("registers all 10 executors (7 instant + 3 async)", () => {
737
+ test("registers all 11 executors (8 instant + 3 async)", () => {
710
738
  const registry = createExecutorRegistry(mockDeps);
711
739
  const types = registry.types();
712
740
 
@@ -715,12 +743,13 @@ describe("createExecutorRegistry", () => {
715
743
  expect(types).toContain("notify");
716
744
  expect(types).toContain("raw-llm");
717
745
  expect(types).toContain("script");
746
+ expect(types).toContain("swarm-script");
718
747
  expect(types).toContain("vcs");
719
748
  expect(types).toContain("validate");
720
749
  expect(types).toContain("agent-task");
721
750
  expect(types).toContain("human-in-the-loop");
722
751
  expect(types).toContain("wait");
723
- expect(types).toHaveLength(10);
752
+ expect(types).toHaveLength(11);
724
753
  });
725
754
 
726
755
  test("instant executors have mode instant, async executors have mode async", () => {
@@ -731,6 +760,7 @@ describe("createExecutorRegistry", () => {
731
760
  "notify",
732
761
  "raw-llm",
733
762
  "script",
763
+ "swarm-script",
734
764
  "vcs",
735
765
  "validate",
736
766
  ];