@desplega.ai/agent-swarm 1.80.0 → 1.80.1

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 (93) 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/scripts/db.ts +391 -0
  8. package/src/be/scripts/embeddings.ts +231 -0
  9. package/src/be/scripts/maintenance.ts +9 -0
  10. package/src/be/scripts/typecheck.ts +193 -0
  11. package/src/cli.tsx +22 -5
  12. package/src/commands/artifact.ts +3 -2
  13. package/src/commands/claude-managed-setup.ts +2 -1
  14. package/src/commands/codex-login.ts +5 -3
  15. package/src/commands/onboard.tsx +2 -1
  16. package/src/commands/runner.ts +72 -10
  17. package/src/commands/setup.tsx +5 -3
  18. package/src/hooks/hook.ts +4 -3
  19. package/src/http/index.ts +40 -29
  20. package/src/http/memory.ts +28 -0
  21. package/src/http/openapi.ts +1 -0
  22. package/src/http/page-proxy.ts +2 -1
  23. package/src/http/route-def.ts +1 -0
  24. package/src/http/schedules.ts +37 -0
  25. package/src/http/scripts.ts +381 -0
  26. package/src/linear/outbound.ts +9 -2
  27. package/src/otel.ts +5 -0
  28. package/src/providers/claude-adapter.ts +22 -1
  29. package/src/scripts-runtime/ctx.ts +23 -0
  30. package/src/scripts-runtime/eval-harness.ts +39 -0
  31. package/src/scripts-runtime/executors/native.ts +229 -0
  32. package/src/scripts-runtime/executors/registry.ts +16 -0
  33. package/src/scripts-runtime/executors/types.ts +63 -0
  34. package/src/scripts-runtime/extract-signature.ts +81 -0
  35. package/src/scripts-runtime/import-allowlist.ts +109 -0
  36. package/src/scripts-runtime/loader.ts +96 -0
  37. package/src/scripts-runtime/redacted.ts +48 -0
  38. package/src/scripts-runtime/sdk-allowlist.ts +29 -0
  39. package/src/scripts-runtime/stdlib/fetch.ts +46 -0
  40. package/src/scripts-runtime/stdlib/glob.ts +8 -0
  41. package/src/scripts-runtime/stdlib/grep.ts +34 -0
  42. package/src/scripts-runtime/stdlib/index.ts +16 -0
  43. package/src/scripts-runtime/stdlib/table.ts +17 -0
  44. package/src/scripts-runtime/swarm-config.ts +35 -0
  45. package/src/scripts-runtime/swarm-sdk.ts +197 -0
  46. package/src/scripts-runtime/types/stdlib.d.ts +104 -0
  47. package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
  48. package/src/server.ts +12 -0
  49. package/src/tests/api-key.test.ts +33 -0
  50. package/src/tests/codex-login.test.ts +1 -1
  51. package/src/tests/linear-outbound-sync.test.ts +109 -0
  52. package/src/tests/mcp-tools.test.ts +69 -0
  53. package/src/tests/redacted.test.ts +29 -0
  54. package/src/tests/runner-tool-spans.test.ts +268 -0
  55. package/src/tests/script-executor-conformance.test.ts +142 -0
  56. package/src/tests/script-executor-registry.test.ts +17 -0
  57. package/src/tests/scripts-db.test.ts +329 -0
  58. package/src/tests/scripts-embeddings.test.ts +291 -0
  59. package/src/tests/scripts-extract-signature.test.ts +47 -0
  60. package/src/tests/scripts-http.test.ts +350 -0
  61. package/src/tests/scripts-import-allowlist.test.ts +55 -0
  62. package/src/tests/scripts-mcp-e2e.test.ts +269 -0
  63. package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
  64. package/src/tests/scripts-runtime.test.ts +289 -0
  65. package/src/tests/sdk-allowlist.test.ts +59 -0
  66. package/src/tests/secret-scrubber.test.ts +35 -1
  67. package/src/tests/swarm-config.test.ts +38 -0
  68. package/src/tests/tool-annotations.test.ts +2 -2
  69. package/src/tests/tool-call-progress.test.ts +30 -0
  70. package/src/tests/workflow-e2e.test.ts +218 -0
  71. package/src/tests/workflow-executors.test.ts +32 -2
  72. package/src/tests/workflow-input-redaction.test.ts +232 -0
  73. package/src/tests/workflow-swarm-script.test.ts +273 -0
  74. package/src/tools/memory-rate.ts +2 -1
  75. package/src/tools/script-common.ts +88 -0
  76. package/src/tools/script-delete.ts +35 -0
  77. package/src/tools/script-query-types.ts +37 -0
  78. package/src/tools/script-run.ts +43 -0
  79. package/src/tools/script-search.ts +32 -0
  80. package/src/tools/script-upsert.ts +43 -0
  81. package/src/tools/tool-config.ts +7 -0
  82. package/src/types.ts +60 -1
  83. package/src/utils/api-key.ts +28 -0
  84. package/src/utils/page-session.ts +8 -6
  85. package/src/utils/secret-scrubber.ts +22 -1
  86. package/src/workflows/engine.ts +12 -4
  87. package/src/workflows/executors/index.ts +1 -0
  88. package/src/workflows/executors/registry.ts +2 -0
  89. package/src/workflows/executors/script.ts +12 -1
  90. package/src/workflows/executors/swarm-script.ts +170 -0
  91. package/src/workflows/input.ts +65 -0
  92. package/src/workflows/recovery.ts +31 -3
  93. package/src/workflows/resume.ts +43 -5
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getApiKey, setApiKey } from "../utils/api-key";
3
+
4
+ describe("getApiKey", () => {
5
+ test("returns empty string when neither var is set", () => {
6
+ expect(getApiKey({})).toBe("");
7
+ });
8
+
9
+ test("returns API_KEY when only legacy var is set", () => {
10
+ expect(getApiKey({ API_KEY: "legacy" })).toBe("legacy");
11
+ });
12
+
13
+ test("returns AGENT_SWARM_API_KEY when only preferred var is set", () => {
14
+ expect(getApiKey({ AGENT_SWARM_API_KEY: "preferred" })).toBe("preferred");
15
+ });
16
+
17
+ test("prefers AGENT_SWARM_API_KEY over API_KEY when both set", () => {
18
+ expect(getApiKey({ AGENT_SWARM_API_KEY: "preferred", API_KEY: "legacy" })).toBe("preferred");
19
+ });
20
+
21
+ test("falls back to API_KEY if AGENT_SWARM_API_KEY is undefined", () => {
22
+ expect(getApiKey({ AGENT_SWARM_API_KEY: undefined, API_KEY: "x" })).toBe("x");
23
+ });
24
+ });
25
+
26
+ describe("setApiKey", () => {
27
+ test("populates both env var names", () => {
28
+ const env: Record<string, string | undefined> = {};
29
+ setApiKey("k", env);
30
+ expect(env.AGENT_SWARM_API_KEY).toBe("k");
31
+ expect(env.API_KEY).toBe("k");
32
+ });
33
+ });
@@ -70,7 +70,7 @@ describe("resolveCodexLoginConfig", () => {
70
70
  expect(promptSecret).toHaveBeenCalledWith(
71
71
  "Swarm API key",
72
72
  "env-secret",
73
- "Press Enter to use API_KEY from the environment",
73
+ "Press Enter to use AGENT_SWARM_API_KEY/API_KEY from the environment",
74
74
  );
75
75
  });
76
76
 
@@ -3,6 +3,7 @@ import { unlink } from "node:fs/promises";
3
3
  import { closeDb, initDb } from "../be/db";
4
4
  import { createTrackerSync, getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
5
5
  import { initLinearOutboundSync, teardownLinearOutboundSync } from "../linear/outbound";
6
+ import { taskSessionMap } from "../linear/sync";
6
7
  import { workflowEventBus } from "../workflows/event-bus";
7
8
 
8
9
  const TEST_DB_PATH = "./test-linear-outbound-sync.sqlite";
@@ -17,6 +18,19 @@ mock.module("../linear/client", () => ({
17
18
  resetLinearClient: () => {},
18
19
  }));
19
20
 
21
+ // Mock the AgentSession helpers in linear/sync so we can assert which activity type
22
+ // the outbound handlers post (`action` vs `thought` vs `response`/`error`).
23
+ const mockPostAgentSessionThought = mock(() => Promise.resolve());
24
+ const mockPostAgentSessionAction = mock(() => Promise.resolve());
25
+ const mockEndAgentSession = mock(() => Promise.resolve());
26
+
27
+ mock.module("../linear/sync", () => ({
28
+ postAgentSessionThought: mockPostAgentSessionThought,
29
+ postAgentSessionAction: mockPostAgentSessionAction,
30
+ endAgentSession: mockEndAgentSession,
31
+ taskSessionMap,
32
+ }));
33
+
20
34
  beforeAll(() => {
21
35
  initDb(TEST_DB_PATH);
22
36
  });
@@ -31,11 +45,16 @@ afterAll(async () => {
31
45
  describe("Linear Outbound Sync", () => {
32
46
  beforeEach(() => {
33
47
  mockCreateComment.mockClear();
48
+ mockPostAgentSessionThought.mockClear();
49
+ mockPostAgentSessionAction.mockClear();
50
+ mockEndAgentSession.mockClear();
51
+ taskSessionMap.clear();
34
52
  initLinearOutboundSync();
35
53
  });
36
54
 
37
55
  afterEach(() => {
38
56
  teardownLinearOutboundSync();
57
+ taskSessionMap.clear();
39
58
  });
40
59
 
41
60
  test("task.completed posts comment to Linear when mapping exists", async () => {
@@ -177,6 +196,96 @@ describe("Linear Outbound Sync", () => {
177
196
  expect(mockCreateComment).toHaveBeenCalledTimes(1);
178
197
  });
179
198
 
199
+ test("task.progress posts an action activity with both action AND parameter when sessionId is mapped", async () => {
200
+ const taskId = "outbound-task-progress";
201
+ taskSessionMap.set(taskId, "linear-session-123");
202
+
203
+ workflowEventBus.emit("task.progress", {
204
+ taskId,
205
+ progress: "📋 Reviewing task details",
206
+ });
207
+
208
+ await new Promise((resolve) => setTimeout(resolve, 10));
209
+
210
+ // Posts as `action` so the update renders as a structured card in Linear's AgentSession
211
+ // panel. Linear's spec requires BOTH `action` AND `parameter` for action-type activities;
212
+ // the original bug was calling postAgentSessionAction with only a single string (parameter
213
+ // undefined), which Linear silently rejected.
214
+ expect(mockPostAgentSessionAction).toHaveBeenCalledTimes(1);
215
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
216
+
217
+ const args = mockPostAgentSessionAction.mock.calls[0] as unknown[];
218
+ expect(args[0]).toBe("linear-session-123");
219
+ // Both action label and parameter must be present and non-empty
220
+ expect(typeof args[1]).toBe("string");
221
+ expect((args[1] as string).length).toBeGreaterThan(0);
222
+ expect(typeof args[2]).toBe("string");
223
+ expect((args[2] as string).length).toBeGreaterThan(0);
224
+ // Parameter carries the actual progress text
225
+ expect(args[2] as string).toBe("📋 Reviewing task details");
226
+ });
227
+
228
+ test("task.progress slices long progress strings into the parameter (cap at 2000)", async () => {
229
+ const taskId = "outbound-task-progress-long";
230
+ taskSessionMap.set(taskId, "linear-session-long");
231
+
232
+ const longProgress = "x".repeat(5000);
233
+ workflowEventBus.emit("task.progress", { taskId, progress: longProgress });
234
+
235
+ await new Promise((resolve) => setTimeout(resolve, 10));
236
+
237
+ expect(mockPostAgentSessionAction).toHaveBeenCalledTimes(1);
238
+ const args = mockPostAgentSessionAction.mock.calls[0] as unknown[];
239
+ expect((args[2] as string).length).toBe(2000);
240
+ });
241
+
242
+ test("task.progress is a no-op when no sessionId is mapped for the task", async () => {
243
+ workflowEventBus.emit("task.progress", {
244
+ taskId: "outbound-task-progress-no-session",
245
+ progress: "should be dropped",
246
+ });
247
+
248
+ await new Promise((resolve) => setTimeout(resolve, 10));
249
+
250
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
251
+ expect(mockPostAgentSessionAction).not.toHaveBeenCalled();
252
+ });
253
+
254
+ test("task.progress is a no-op when progress string is missing", async () => {
255
+ taskSessionMap.set("outbound-task-progress-empty", "linear-session-empty");
256
+
257
+ workflowEventBus.emit("task.progress", {
258
+ taskId: "outbound-task-progress-empty",
259
+ });
260
+
261
+ await new Promise((resolve) => setTimeout(resolve, 10));
262
+
263
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
264
+ expect(mockPostAgentSessionAction).not.toHaveBeenCalled();
265
+ });
266
+
267
+ test("task.created for Linear-sourced tasks still posts an action activity (with parameter)", async () => {
268
+ const taskId = "outbound-task-created-linear";
269
+ taskSessionMap.set(taskId, "linear-session-created");
270
+
271
+ workflowEventBus.emit("task.created", {
272
+ taskId,
273
+ source: "linear",
274
+ });
275
+
276
+ await new Promise((resolve) => setTimeout(resolve, 10));
277
+
278
+ expect(mockPostAgentSessionAction).toHaveBeenCalledTimes(1);
279
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
280
+
281
+ const args = mockPostAgentSessionAction.mock.calls[0] as unknown[];
282
+ expect(args[0]).toBe("linear-session-created");
283
+ expect(args[1]).toBe("Processing");
284
+ // parameter (3rd positional arg) must be present for `action` activities to be valid
285
+ expect(typeof args[2]).toBe("string");
286
+ expect(args[2] as string).toContain(taskId);
287
+ });
288
+
180
289
  test("teardown removes event listeners", async () => {
181
290
  teardownLinearOutboundSync();
182
291
 
@@ -0,0 +1,69 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb } from "../be/db";
4
+ import { createServer } from "../server";
5
+
6
+ const TEST_DB_PATH = "./test-mcp-tools.sqlite";
7
+
8
+ type RegisteredTool = {
9
+ title?: string;
10
+ description?: string;
11
+ inputSchema?: unknown;
12
+ outputSchema?: unknown;
13
+ annotations?: Record<string, unknown>;
14
+ };
15
+
16
+ async function removeDbFiles(path: string): Promise<void> {
17
+ for (const suffix of ["", "-wal", "-shm"]) {
18
+ try {
19
+ await unlink(path + suffix);
20
+ } catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
22
+ }
23
+ }
24
+ }
25
+
26
+ describe("script MCP tools", () => {
27
+ let tools: Record<string, RegisteredTool>;
28
+ let savedDatabasePath: string | undefined;
29
+
30
+ beforeAll(async () => {
31
+ savedDatabasePath = process.env.DATABASE_PATH;
32
+ process.env.DATABASE_PATH = TEST_DB_PATH;
33
+ await removeDbFiles(TEST_DB_PATH);
34
+ const server = createServer();
35
+ tools = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
36
+ ._registeredTools;
37
+ });
38
+
39
+ afterAll(async () => {
40
+ closeDb();
41
+ if (savedDatabasePath === undefined) delete process.env.DATABASE_PATH;
42
+ else process.env.DATABASE_PATH = savedDatabasePath;
43
+ await removeDbFiles(TEST_DB_PATH);
44
+ });
45
+
46
+ test("registers all script tools with schemas and documented descriptions", () => {
47
+ const expected = {
48
+ "script-search":
49
+ "Semantic search over swarm-shared TypeScript scripts (catalog persisted in the agent-swarm DB; callable from agents and workflows). For ephemeral throwaway TS on your local machine, use code-mode instead.",
50
+ "script-run":
51
+ "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`.",
52
+ "script-upsert":
53
+ "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`.",
54
+ "script-delete":
55
+ "Remove a swarm-shared script from the catalog. Versions table preserves history.",
56
+ "script-query-types":
57
+ "Fetch the signature + the auto-generated `swarm-sdk.d.ts` (derived from the live MCP tool registry) + the `stdlib.d.ts` blobs — for IDE-style introspection before authoring or running a script. The same types are used by `script-upsert`'s typecheck pass, so they are authoritative.",
58
+ };
59
+
60
+ for (const [name, description] of Object.entries(expected)) {
61
+ expect(tools[name]).toBeDefined();
62
+ expect(tools[name].title).toBeTruthy();
63
+ expect(tools[name].description).toBe(description);
64
+ expect(tools[name].inputSchema).toBeTruthy();
65
+ expect(tools[name].outputSchema).toBeTruthy();
66
+ expect(tools[name].annotations).toBeTruthy();
67
+ }
68
+ });
69
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { inspect } from "node:util";
3
+ import { Redacted } from "../scripts-runtime/redacted";
4
+
5
+ describe("Redacted", () => {
6
+ test("stringification surfaces are redacted", () => {
7
+ const secret = Redacted.make("hunter2", { type: "user", isSecret: true });
8
+ expect(String(secret)).toBe("<redacted>");
9
+ expect(JSON.stringify({ secret })).toBe('{"secret":"<redacted>"}');
10
+ expect(inspect(secret)).toContain("<redacted>");
11
+ expect(inspect(secret)).not.toContain("hunter2");
12
+ });
13
+
14
+ test("value round-trips the original value", () => {
15
+ const value = { nested: true };
16
+ const wrapped = Redacted.make(value);
17
+ expect(Redacted.value(wrapped)).toBe(value);
18
+ });
19
+
20
+ test("meta returns the stored metadata", () => {
21
+ const wrapped = Redacted.make("abc", { type: "system", isSecret: false });
22
+ expect(Redacted.meta(wrapped)).toEqual({ type: "system", isSecret: false });
23
+ expect(Redacted.isSecret(wrapped)).toBe(false);
24
+ });
25
+
26
+ test("unregistered objects throw", () => {
27
+ expect(() => Redacted.value({} as never)).toThrow("Redacted value was not in registry");
28
+ });
29
+ });
@@ -0,0 +1,268 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type ActiveToolSpanEntry, implicitCloseActiveToolSpans } from "../commands/runner";
3
+ import type { Attributes, AttributeValue, SwarmSpan } from "../otel";
4
+
5
+ /**
6
+ * Minimal recording SwarmSpan stub for asserting attributes/status/end calls.
7
+ * Keeps the runner-tool-spans unit test isolated from the real OTel SDK.
8
+ */
9
+ type RecordingSpan = SwarmSpan & {
10
+ attrs: Record<string, AttributeValue>;
11
+ status?: { code: number; message?: string };
12
+ ended: boolean;
13
+ };
14
+
15
+ function makeSpan(): RecordingSpan {
16
+ const span: RecordingSpan = {
17
+ attrs: {},
18
+ ended: false,
19
+ setAttribute(key: string, value: AttributeValue) {
20
+ this.attrs[key] = value;
21
+ return this;
22
+ },
23
+ setAttributes(attributes: Attributes) {
24
+ for (const [k, v] of Object.entries(attributes)) {
25
+ if (v !== undefined) this.attrs[k] = v;
26
+ }
27
+ return this;
28
+ },
29
+ addEvent() {
30
+ return this;
31
+ },
32
+ recordException() {},
33
+ setStatus(s) {
34
+ this.status = s;
35
+ return this;
36
+ },
37
+ end() {
38
+ this.ended = true;
39
+ },
40
+ };
41
+ return span;
42
+ }
43
+
44
+ function entry(span: SwarmSpan, opts: { startedAt: number }): ActiveToolSpanEntry {
45
+ return { span, startedAt: opts.startedAt };
46
+ }
47
+
48
+ describe("implicitCloseActiveToolSpans", () => {
49
+ test("closes worker.tool spans with implicit_close=true and accurate duration_ms", () => {
50
+ const span = makeSpan();
51
+ const map = new Map<string, ActiveToolSpanEntry>();
52
+ map.set("call-1", entry(span, { startedAt: 1_000 }));
53
+
54
+ const closed = implicitCloseActiveToolSpans(map, 1_750);
55
+
56
+ expect(closed).toBe(1);
57
+ expect(span.ended).toBe(true);
58
+ expect(span.attrs["agentswarm.tool.implicit_close"]).toBe(true);
59
+ expect(span.attrs["agentswarm.tool.duration_ms"]).toBe(750);
60
+ expect(span.attrs["agentswarm.tool.call_id"]).toBe("call-1");
61
+ expect(span.status?.code).toBe(1);
62
+ expect(map.has("call-1")).toBe(false);
63
+ });
64
+
65
+ test("closes MCP spans at the assistant-message boundary too", () => {
66
+ const mcpSpan = makeSpan();
67
+ const harnessSpan = makeSpan();
68
+ const map = new Map<string, ActiveToolSpanEntry>();
69
+ map.set("mcp-1", entry(mcpSpan, { startedAt: 1_000 }));
70
+ map.set("call-1", entry(harnessSpan, { startedAt: 1_000 }));
71
+
72
+ const closed = implicitCloseActiveToolSpans(map, 2_000);
73
+
74
+ expect(closed).toBe(2);
75
+ expect(harnessSpan.ended).toBe(true);
76
+ expect(harnessSpan.attrs["agentswarm.tool.implicit_close"]).toBe(true);
77
+ expect(mcpSpan.ended).toBe(true);
78
+ expect(mcpSpan.attrs["agentswarm.tool.implicit_close"]).toBe(true);
79
+ expect(mcpSpan.attrs["agentswarm.tool.duration_ms"]).toBe(1_000);
80
+ expect(mcpSpan.attrs["agentswarm.tool.call_id"]).toBe("mcp-1");
81
+ expect(mcpSpan.status?.code).toBe(1);
82
+ expect(map.size).toBe(0);
83
+ });
84
+
85
+ test("no-op on an empty map (and returns 0)", () => {
86
+ const map = new Map<string, ActiveToolSpanEntry>();
87
+ const closed = implicitCloseActiveToolSpans(map, Date.now());
88
+ expect(closed).toBe(0);
89
+ expect(map.size).toBe(0);
90
+ });
91
+
92
+ test("closes multiple parallel spans (mix of harness and MCP) from the same turn", () => {
93
+ const a = makeSpan();
94
+ const b = makeSpan();
95
+ const c = makeSpan();
96
+ const map = new Map<string, ActiveToolSpanEntry>();
97
+ map.set("a", entry(a, { startedAt: 100 }));
98
+ map.set("b", entry(b, { startedAt: 200 }));
99
+ map.set("c", entry(c, { startedAt: 300 }));
100
+
101
+ const closed = implicitCloseActiveToolSpans(map, 1_000);
102
+
103
+ expect(closed).toBe(3);
104
+ expect(a.attrs["agentswarm.tool.duration_ms"]).toBe(900);
105
+ expect(b.attrs["agentswarm.tool.duration_ms"]).toBe(800);
106
+ expect(c.attrs["agentswarm.tool.duration_ms"]).toBe(700);
107
+ for (const span of [a, b, c]) {
108
+ expect(span.ended).toBe(true);
109
+ expect(span.attrs["agentswarm.tool.implicit_close"]).toBe(true);
110
+ }
111
+ expect(map.size).toBe(0);
112
+ });
113
+
114
+ test("called twice after a single turn → second call is a no-op", () => {
115
+ const span = makeSpan();
116
+ const map = new Map<string, ActiveToolSpanEntry>();
117
+ map.set("call-1", entry(span, { startedAt: 1_000 }));
118
+
119
+ expect(implicitCloseActiveToolSpans(map, 1_500)).toBe(1);
120
+ expect(implicitCloseActiveToolSpans(map, 2_000)).toBe(0);
121
+ // The span should not be ended twice or get a second duration overwrite.
122
+ expect(span.attrs["agentswarm.tool.duration_ms"]).toBe(500);
123
+ });
124
+ });
125
+
126
+ describe("end-to-end boundary semantics (helper integration)", () => {
127
+ // Simulates the runner's event-handler contract:
128
+ // - tool_start adds an entry to the active-tool-spans map
129
+ // - assistant-message boundary calls `implicitCloseActiveToolSpans`
130
+ // - explicit tool_end closes the entry directly (no implicit_close attr)
131
+ // - session shutdown calls a `closeActiveToolSpans` analog as a safety net
132
+ // We don't pull in the runner module directly (it imports the entire
133
+ // provider/HTTP surface); instead the test mirrors its small fragment of
134
+ // logic on the same exported helper.
135
+
136
+ function startToolSpan(
137
+ map: Map<string, ActiveToolSpanEntry>,
138
+ toolCallId: string,
139
+ opts: { startedAt: number },
140
+ ): RecordingSpan {
141
+ const span = makeSpan();
142
+ map.set(toolCallId, { span, startedAt: opts.startedAt });
143
+ return span;
144
+ }
145
+
146
+ function endToolSpan(
147
+ map: Map<string, ActiveToolSpanEntry>,
148
+ toolCallId: string,
149
+ now: number,
150
+ ): void {
151
+ // Mirrors the explicit `tool_end` branch in runner.ts: sets duration + OK
152
+ // status and ends the span. Crucially does NOT set `implicit_close`.
153
+ const active = map.get(toolCallId);
154
+ if (!active) return;
155
+ active.span.setAttributes({
156
+ "agentswarm.tool.duration_ms": now - active.startedAt,
157
+ });
158
+ active.span.setStatus({ code: 1 });
159
+ active.span.end();
160
+ map.delete(toolCallId);
161
+ }
162
+
163
+ function shutdownSafetyNet(
164
+ map: Map<string, ActiveToolSpanEntry>,
165
+ now: number,
166
+ ): { closed: number } {
167
+ // Mirrors `closeActiveToolSpans` (the safety net). After the boundary fix,
168
+ // we expect this to be a no-op in the typical case.
169
+ let closed = 0;
170
+ for (const [toolCallId, active] of map) {
171
+ active.span.setAttributes({
172
+ "agentswarm.tool.duration_ms": now - active.startedAt,
173
+ "agentswarm.tool.unclosed": true,
174
+ "agentswarm.tool.call_id": toolCallId,
175
+ });
176
+ active.span.end();
177
+ map.delete(toolCallId);
178
+ closed++;
179
+ }
180
+ return { closed };
181
+ }
182
+
183
+ test("tool_start → assistant boundary → span closes with implicit_close=true", () => {
184
+ const map = new Map<string, ActiveToolSpanEntry>();
185
+ const span = startToolSpan(map, "call-1", { startedAt: 1_000 });
186
+
187
+ implicitCloseActiveToolSpans(map, 1_500);
188
+
189
+ expect(span.ended).toBe(true);
190
+ expect(span.attrs["agentswarm.tool.implicit_close"]).toBe(true);
191
+ expect(span.attrs["agentswarm.tool.duration_ms"]).toBe(500);
192
+ expect(span.attrs["agentswarm.tool.unclosed"]).toBeUndefined();
193
+ expect(map.size).toBe(0);
194
+ });
195
+
196
+ test("tool_start → tool_end → span closes WITHOUT implicit_close", () => {
197
+ const map = new Map<string, ActiveToolSpanEntry>();
198
+ const span = startToolSpan(map, "call-1", { startedAt: 1_000 });
199
+
200
+ endToolSpan(map, "call-1", 1_200);
201
+
202
+ expect(span.ended).toBe(true);
203
+ expect(span.attrs["agentswarm.tool.duration_ms"]).toBe(200);
204
+ expect(span.attrs["agentswarm.tool.implicit_close"]).toBeUndefined();
205
+ expect(map.size).toBe(0);
206
+ });
207
+
208
+ test("MCP tool spans are also closed by the assistant-message boundary", () => {
209
+ const map = new Map<string, ActiveToolSpanEntry>();
210
+ const mcp = startToolSpan(map, "mcp-1", { startedAt: 1_000 });
211
+
212
+ implicitCloseActiveToolSpans(map, 2_000);
213
+
214
+ expect(mcp.ended).toBe(true);
215
+ expect(mcp.attrs["agentswarm.tool.implicit_close"]).toBe(true);
216
+ expect(mcp.attrs["agentswarm.tool.duration_ms"]).toBe(1_000);
217
+ expect(map.size).toBe(0);
218
+ });
219
+
220
+ test("mixed harness + MCP tool_starts → both kinds closed with implicit_close=true at boundary", () => {
221
+ // Simulates a turn where the model invokes both a harness tool (Bash) and
222
+ // an MCP tool (e.g. mcp__agent-swarm__store-progress), and the next
223
+ // assistant message arrives without any `tool_end` events from the
224
+ // adapter (Claude SDK behavior).
225
+ const map = new Map<string, ActiveToolSpanEntry>();
226
+ const harness = startToolSpan(map, "bash-1", { startedAt: 1_000 });
227
+ const mcp = startToolSpan(map, "mcp-1", { startedAt: 1_050 });
228
+
229
+ const closed = implicitCloseActiveToolSpans(map, 2_500);
230
+
231
+ expect(closed).toBe(2);
232
+ expect(harness.ended).toBe(true);
233
+ expect(mcp.ended).toBe(true);
234
+ expect(harness.attrs["agentswarm.tool.implicit_close"]).toBe(true);
235
+ expect(mcp.attrs["agentswarm.tool.implicit_close"]).toBe(true);
236
+ expect(harness.attrs["agentswarm.tool.duration_ms"]).toBe(1_500);
237
+ expect(mcp.attrs["agentswarm.tool.duration_ms"]).toBe(1_450);
238
+ expect(harness.attrs["agentswarm.tool.unclosed"]).toBeUndefined();
239
+ expect(mcp.attrs["agentswarm.tool.unclosed"]).toBeUndefined();
240
+ expect(map.size).toBe(0);
241
+ });
242
+
243
+ test("after boundary closes all spans, shutdown safety net closes 0", () => {
244
+ const map = new Map<string, ActiveToolSpanEntry>();
245
+ startToolSpan(map, "call-1", { startedAt: 1_000 });
246
+ startToolSpan(map, "call-2", { startedAt: 1_100 });
247
+
248
+ implicitCloseActiveToolSpans(map, 1_800);
249
+ expect(map.size).toBe(0);
250
+
251
+ const { closed } = shutdownSafetyNet(map, 2_000);
252
+ expect(closed).toBe(0);
253
+ });
254
+
255
+ test("if session ends before any boundary fires, safety net flags `unclosed`", () => {
256
+ const map = new Map<string, ActiveToolSpanEntry>();
257
+ const span = startToolSpan(map, "call-1", { startedAt: 1_000 });
258
+
259
+ // No boundary, straight to shutdown.
260
+ const { closed } = shutdownSafetyNet(map, 5_000);
261
+
262
+ expect(closed).toBe(1);
263
+ expect(span.ended).toBe(true);
264
+ expect(span.attrs["agentswarm.tool.unclosed"]).toBe(true);
265
+ expect(span.attrs["agentswarm.tool.implicit_close"]).toBeUndefined();
266
+ expect(span.attrs["agentswarm.tool.duration_ms"]).toBe(4_000);
267
+ });
268
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { NativeScriptExecutor } from "../scripts-runtime/executors/native";
3
+ import type {
4
+ ExecutorInput,
5
+ ExecutorOutput,
6
+ ScriptExecutor,
7
+ } from "../scripts-runtime/executors/types";
8
+ import { DEFAULT_SCRIPT_RESOURCES } from "../scripts-runtime/executors/types";
9
+
10
+ const payload = {
11
+ system: {
12
+ apiKey: { value: "conformance-secret", isSecret: true as const },
13
+ agentId: { value: "agent-1", isSecret: false as const },
14
+ mcpBaseUrl: { value: "http://localhost:3013", isSecret: false as const },
15
+ },
16
+ user: {},
17
+ };
18
+
19
+ function input(overrides: Partial<ExecutorInput> = {}): ExecutorInput {
20
+ return {
21
+ source: "export default async (args) => args.x + 1;",
22
+ args: { x: 1 },
23
+ configPayload: payload,
24
+ resources: {
25
+ ...DEFAULT_SCRIPT_RESOURCES,
26
+ memoryMb: 2048,
27
+ wallClockMs: 1_000,
28
+ ...overrides.resources,
29
+ },
30
+ fsMode: "none",
31
+ network: "open",
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ class FakeScriptExecutor implements ScriptExecutor {
37
+ readonly name = "fake";
38
+
39
+ async run(runInput: ExecutorInput): Promise<ExecutorOutput> {
40
+ if (runInput.fsMode === "workspace-rw") {
41
+ return {
42
+ result: undefined,
43
+ stdout: "",
44
+ stderr: "workspace-rw not supported",
45
+ truncated: { stdout: false, stderr: false },
46
+ durationMs: 0,
47
+ exitCode: 1,
48
+ error: "executor_error",
49
+ };
50
+ }
51
+
52
+ if (runInput.signal?.aborted) {
53
+ return {
54
+ result: undefined,
55
+ stdout: "",
56
+ stderr: "",
57
+ truncated: { stdout: false, stderr: false },
58
+ durationMs: 0,
59
+ exitCode: 1,
60
+ error: "killed",
61
+ };
62
+ }
63
+
64
+ const stdout = "x".repeat(runInput.resources.maxStdoutBytes + 10);
65
+ return {
66
+ result: runInput.configPayload.system.apiKey.value,
67
+ stdout: stdout.slice(0, runInput.resources.maxStdoutBytes),
68
+ stderr: "",
69
+ truncated: { stdout: true, stderr: false },
70
+ durationMs: 1,
71
+ exitCode: 0,
72
+ };
73
+ }
74
+ }
75
+
76
+ function conformance(name: string, makeExecutor: () => ScriptExecutor) {
77
+ describe(`${name} ScriptExecutor conformance`, () => {
78
+ test("happy path run", async () => {
79
+ const output = await makeExecutor().run(
80
+ input({
81
+ source: "export default async (args) => args.x + 1;",
82
+ args: { x: 2 },
83
+ }),
84
+ );
85
+ expect(output.exitCode).toBe(0);
86
+ expect(output.error).toBeUndefined();
87
+ });
88
+
89
+ test("stdout cap is honored", async () => {
90
+ const output = await makeExecutor().run(
91
+ input({
92
+ resources: {
93
+ ...DEFAULT_SCRIPT_RESOURCES,
94
+ memoryMb: 2048,
95
+ maxStdoutBytes: 64,
96
+ wallClockMs: 1_000,
97
+ },
98
+ source: "export default async () => { console.log('x'.repeat(512)); return true; };",
99
+ }),
100
+ );
101
+ expect(output.stdout.length).toBeLessThanOrEqual(64);
102
+ expect(output.truncated.stdout).toBe(true);
103
+ });
104
+
105
+ test("workspace-rw returns executor_error", async () => {
106
+ const output = await makeExecutor().run(input({ fsMode: "workspace-rw" }));
107
+ expect(output.error).toBe("executor_error");
108
+ });
109
+
110
+ test("config payload is delivered", async () => {
111
+ const output = await makeExecutor().run(
112
+ input({
113
+ source:
114
+ "export default async (_args, ctx) => ctx.stdlib.Redacted.value(ctx.swarm.config.apiKey);",
115
+ }),
116
+ );
117
+ expect(output.result).toBe("conformance-secret");
118
+ });
119
+ });
120
+ }
121
+
122
+ conformance("native", () => new NativeScriptExecutor());
123
+ conformance("fake", () => new FakeScriptExecutor());
124
+
125
+ describe("native-only executor behavior", () => {
126
+ test("timeout maps to timeout", async () => {
127
+ const output = await new NativeScriptExecutor().run(
128
+ input({
129
+ resources: { ...DEFAULT_SCRIPT_RESOURCES, memoryMb: 2048, wallClockMs: 100 },
130
+ source: "export default async () => new Promise(() => {});",
131
+ }),
132
+ );
133
+ expect(output.error).toBe("timeout");
134
+ });
135
+
136
+ test("AbortSignal maps to killed", async () => {
137
+ const controller = new AbortController();
138
+ controller.abort();
139
+ const output = await new NativeScriptExecutor().run(input({ signal: controller.signal }));
140
+ expect(output.error).toBe("killed");
141
+ });
142
+ });