@desplega.ai/agent-swarm 1.88.0 → 1.90.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 (64) hide show
  1. package/README.md +7 -0
  2. package/openapi.json +41 -1
  3. package/package.json +3 -2
  4. package/plugin/skills/composio/SKILL.md +173 -0
  5. package/plugin/skills/composio-gmail/SKILL.md +83 -0
  6. package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
  7. package/plugin/skills/composio-google-docs/SKILL.md +71 -0
  8. package/src/be/db.ts +353 -2
  9. package/src/be/migrations/081_metrics.sql +39 -0
  10. package/src/be/migrations/082_user_audit_fields.sql +120 -0
  11. package/src/be/modelsdev-cache.json +3413 -1423
  12. package/src/be/seed-skills/index.ts +7 -0
  13. package/src/cli.tsx +18 -0
  14. package/src/commands/runner.ts +153 -22
  15. package/src/commands/x.ts +118 -0
  16. package/src/github/handlers.ts +40 -1
  17. package/src/heartbeat/heartbeat.ts +80 -12
  18. package/src/http/active-sessions.ts +32 -1
  19. package/src/http/auth.ts +36 -0
  20. package/src/http/core.ts +20 -16
  21. package/src/http/db-query.ts +20 -0
  22. package/src/http/index.ts +2 -0
  23. package/src/http/metrics.ts +447 -0
  24. package/src/http/operator-actor.ts +9 -0
  25. package/src/http/poll.ts +11 -1
  26. package/src/http/tasks.ts +6 -1
  27. package/src/http/workflows.ts +5 -1
  28. package/src/metrics/version.ts +26 -0
  29. package/src/prompts/base-prompt.ts +8 -0
  30. package/src/prompts/session-templates.ts +23 -0
  31. package/src/providers/opencode-adapter.ts +22 -6
  32. package/src/server.ts +10 -1
  33. package/src/tasks/worker-follow-up.ts +19 -1
  34. package/src/tests/base-prompt.test.ts +35 -0
  35. package/src/tests/budget-claim-gate.test.ts +26 -0
  36. package/src/tests/core-auth.test.ts +8 -1
  37. package/src/tests/events-http.test.ts +6 -2
  38. package/src/tests/github-handlers-cancel-config.test.ts +262 -0
  39. package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
  40. package/src/tests/heartbeat.test.ts +84 -3
  41. package/src/tests/http-api-integration.test.ts +3 -1
  42. package/src/tests/metrics-http.test.ts +247 -0
  43. package/src/tests/opencode-adapter.test.ts +90 -30
  44. package/src/tests/runner-repo-autostash.test.ts +117 -0
  45. package/src/tests/runner-requester-profile.test.ts +25 -0
  46. package/src/tests/runner-skills-refresh.test.ts +1 -1
  47. package/src/tests/swarm-x-tool.test.ts +90 -0
  48. package/src/tests/system-default-skills.test.ts +3 -0
  49. package/src/tests/ui-logs-parser.test.ts +271 -0
  50. package/src/tests/user-token-rest-auth.test.ts +129 -0
  51. package/src/tests/workflow-async-v2.test.ts +23 -0
  52. package/src/tests/x-composio.test.ts +122 -0
  53. package/src/tools/create-metric.ts +191 -0
  54. package/src/tools/swarm-x.ts +116 -0
  55. package/src/tools/tool-config.ts +6 -0
  56. package/src/types.ts +120 -0
  57. package/src/utils/request-auth-context.ts +28 -0
  58. package/src/utils/skills-refresh.ts +2 -2
  59. package/src/workflows/engine.ts +24 -2
  60. package/src/workflows/executors/agent-task.ts +2 -0
  61. package/src/x/composio.ts +295 -0
  62. package/templates/skills/attio-interaction/SKILL.md +279 -0
  63. package/templates/skills/attio-interaction/config.json +14 -0
  64. package/templates/skills/attio-interaction/content.md +272 -0
@@ -0,0 +1,247 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import crypto from "node:crypto";
3
+ import { unlink } from "node:fs/promises";
4
+ import {
5
+ createServer as createHttpServer,
6
+ type IncomingMessage,
7
+ type Server,
8
+ type ServerResponse,
9
+ } from "node:http";
10
+ import { closeDb, getMetricVersions, initDb } from "../be/db";
11
+ import { handleMetrics } from "../http/metrics";
12
+ import { getPathSegments, parseQueryParams } from "../http/utils";
13
+ import type { Metric } from "../types";
14
+
15
+ const TEST_DB_PATH = "./test-metrics-http.sqlite";
16
+ const TEST_PORT = 13083;
17
+ const BASE = `http://localhost:${TEST_PORT}`;
18
+
19
+ type MetricRunResponse = {
20
+ widgets: Array<{
21
+ widget: { id: string };
22
+ result: {
23
+ columns: string[];
24
+ rows: Record<string, unknown>[];
25
+ };
26
+ }>;
27
+ result: {
28
+ columns: string[];
29
+ rows: Record<string, unknown>[];
30
+ };
31
+ };
32
+
33
+ function createTestServer(): Server {
34
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
35
+ res.setHeader("Content-Type", "application/json");
36
+ const pathSegments = getPathSegments(req.url || "");
37
+ const queryParams = parseQueryParams(req.url || "");
38
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
39
+ const handled = await handleMetrics(req, res, pathSegments, queryParams, myAgentId);
40
+ if (!handled) {
41
+ res.writeHead(404);
42
+ res.end(JSON.stringify({ error: "not found" }));
43
+ }
44
+ });
45
+ }
46
+
47
+ describe("Metrics HTTP API", () => {
48
+ let server: Server;
49
+ const agentId = crypto.randomUUID();
50
+ const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
51
+
52
+ beforeAll(async () => {
53
+ for (const suffix of ["", "-wal", "-shm"]) {
54
+ try {
55
+ await unlink(`${TEST_DB_PATH}${suffix}`);
56
+ } catch {}
57
+ }
58
+ initDb(TEST_DB_PATH);
59
+ server = createTestServer();
60
+ await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
61
+ });
62
+
63
+ afterAll(async () => {
64
+ await new Promise<void>((resolve) => server.close(() => resolve()));
65
+ closeDb();
66
+ for (const suffix of ["", "-wal", "-shm"]) {
67
+ try {
68
+ await unlink(`${TEST_DB_PATH}${suffix}`);
69
+ } catch {}
70
+ }
71
+ });
72
+
73
+ test("fresh DB seeds starter metrics", async () => {
74
+ const res = await fetch(`${BASE}/api/metrics/definitions?fields=full`);
75
+ expect(res.status).toBe(200);
76
+ const body = (await res.json()) as { metrics: Metric[]; total: number };
77
+ expect(body.total).toBeGreaterThanOrEqual(1);
78
+ const starter = body.metrics.find((metric) => metric.slug === "swarm-operations-overview");
79
+ expect(starter?.definition.widgets.map((widget) => widget.viz.type)).toContain("multi-line");
80
+ });
81
+
82
+ test("create, run, update snapshots prior definition", async () => {
83
+ const created = await fetch(`${BASE}/api/metrics/definitions`, {
84
+ method: "POST",
85
+ headers,
86
+ body: JSON.stringify({
87
+ slug: "test-count",
88
+ title: "Test Count",
89
+ description: "Counts agent rows",
90
+ definition: {
91
+ version: 1,
92
+ widgets: [
93
+ {
94
+ id: "agent-count",
95
+ title: "Agent count",
96
+ query: { sql: "SELECT COUNT(*) AS count FROM agents", maxRows: 10 },
97
+ viz: { type: "stat", value: "count", format: "integer" },
98
+ },
99
+ ],
100
+ },
101
+ }),
102
+ });
103
+ expect(created.status).toBe(201);
104
+ const { id } = (await created.json()) as { id: string; version: number };
105
+
106
+ const run = await fetch(`${BASE}/api/metrics/definitions/${id}/run`, {
107
+ method: "POST",
108
+ headers,
109
+ body: JSON.stringify({ variables: {} }),
110
+ });
111
+ expect(run.status).toBe(200);
112
+ const runBody = (await run.json()) as MetricRunResponse;
113
+ expect(runBody.widgets[0]?.result.columns).toEqual(["count"]);
114
+ expect(runBody.widgets[0]?.result.rows[0]).toHaveProperty("count");
115
+
116
+ const updated = await fetch(`${BASE}/api/metrics/definitions/${id}`, {
117
+ method: "PUT",
118
+ headers,
119
+ body: JSON.stringify({
120
+ title: "Updated Count",
121
+ definition: {
122
+ version: 1,
123
+ widgets: [
124
+ {
125
+ id: "task-count",
126
+ title: "Task count",
127
+ query: { sql: "SELECT COUNT(*) AS count FROM agent_tasks", maxRows: 10 },
128
+ viz: { type: "stat", value: "count", format: "integer" },
129
+ },
130
+ ],
131
+ },
132
+ }),
133
+ });
134
+ expect(updated.status).toBe(200);
135
+ expect(getMetricVersions(id)).toHaveLength(1);
136
+ expect(getMetricVersions(id)[0]?.snapshot.title).toBe("Test Count");
137
+ });
138
+
139
+ test("humans can create metrics through the UI without an agent header", async () => {
140
+ const created = await fetch(`${BASE}/api/metrics/definitions`, {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json" },
143
+ body: JSON.stringify({
144
+ slug: "ui-owned-count",
145
+ title: "UI Owned Count",
146
+ definition: {
147
+ version: 1,
148
+ widgets: [
149
+ {
150
+ id: "task-count",
151
+ title: "Task count",
152
+ query: { sql: "SELECT COUNT(*) AS count FROM agent_tasks", maxRows: 10 },
153
+ viz: { type: "stat", value: "count", format: "integer" },
154
+ },
155
+ ],
156
+ },
157
+ }),
158
+ });
159
+ expect(created.status).toBe(201);
160
+ const { id } = (await created.json()) as { id: string; version: number };
161
+
162
+ const run = await fetch(`${BASE}/api/metrics/definitions/${id}/run`, {
163
+ method: "POST",
164
+ headers: { "Content-Type": "application/json" },
165
+ body: JSON.stringify({ variables: {} }),
166
+ });
167
+ expect(run.status).toBe(200);
168
+ const runBody = (await run.json()) as MetricRunResponse;
169
+ expect(runBody.widgets[0]?.result.rows[0]).toHaveProperty("count");
170
+ });
171
+
172
+ test("run binds metric variables into query params", async () => {
173
+ const created = await fetch(`${BASE}/api/metrics/definitions`, {
174
+ method: "POST",
175
+ headers,
176
+ body: JSON.stringify({
177
+ slug: "variable-count",
178
+ title: "Variable Count",
179
+ definition: {
180
+ version: 1,
181
+ variables: [
182
+ {
183
+ key: "status",
184
+ label: "Status",
185
+ type: "select",
186
+ defaultValue: "pending",
187
+ options: [
188
+ { label: "Pending", value: "pending" },
189
+ { label: "Completed", value: "completed" },
190
+ ],
191
+ },
192
+ ],
193
+ widgets: [
194
+ {
195
+ id: "status-count",
196
+ title: "Status count",
197
+ query: {
198
+ sql: "SELECT COUNT(*) AS count FROM agent_tasks WHERE status = ?",
199
+ params: ["{{status}}"],
200
+ maxRows: 10,
201
+ },
202
+ viz: { type: "stat", value: "count", format: "integer" },
203
+ },
204
+ ],
205
+ },
206
+ }),
207
+ });
208
+ expect(created.status).toBe(201);
209
+ const { id } = (await created.json()) as { id: string; version: number };
210
+
211
+ const run = await fetch(`${BASE}/api/metrics/definitions/${id}/run`, {
212
+ method: "POST",
213
+ headers,
214
+ body: JSON.stringify({ variables: { status: "completed" } }),
215
+ });
216
+ expect(run.status).toBe(200);
217
+ const runBody = (await run.json()) as MetricRunResponse & {
218
+ variables: Record<string, string>;
219
+ };
220
+ expect(runBody.variables.status).toBe("completed");
221
+ expect(runBody.widgets[0]?.result.rows[0]).toHaveProperty("count");
222
+ });
223
+
224
+ test("saved metric SQL rejects writes and multiple statements", async () => {
225
+ for (const sql of ["DELETE FROM agent_tasks", "SELECT 1; SELECT 2"]) {
226
+ const res = await fetch(`${BASE}/api/metrics/definitions`, {
227
+ method: "POST",
228
+ headers,
229
+ body: JSON.stringify({
230
+ title: "Bad Metric",
231
+ definition: {
232
+ version: 1,
233
+ widgets: [
234
+ {
235
+ id: "bad",
236
+ title: "Bad",
237
+ query: { sql },
238
+ viz: { type: "stat", value: "x" },
239
+ },
240
+ ],
241
+ },
242
+ }),
243
+ });
244
+ expect(res.status).toBe(400);
245
+ }
246
+ });
247
+ });
@@ -48,7 +48,7 @@ let lastCreateOpencodeConfig: unknown;
48
48
  async function driveSession(
49
49
  events: OpencodeEvent[],
50
50
  cfg: ProviderSessionConfig = testConfig(),
51
- ): Promise<{ emitted: ProviderEvent[]; result: ProviderResult }> {
51
+ ): Promise<{ emitted: ProviderEvent[]; result: ProviderResult; serverCloseCalls: () => number }> {
52
52
  const emitted: ProviderEvent[] = [];
53
53
 
54
54
  // Build the fake client/server pair used by the mock
@@ -68,7 +68,8 @@ async function driveSession(
68
68
  },
69
69
  };
70
70
 
71
- const fakeServer = { url: "http://127.0.0.1:12345", close: mock(() => {}) };
71
+ const closeServer = mock(() => {});
72
+ const fakeServer = { url: "http://127.0.0.1:12345", close: closeServer };
72
73
 
73
74
  // Install mock BEFORE importing the adapter (Bun hoists mock.module)
74
75
  mock.module("@opencode-ai/sdk", () => ({
@@ -88,7 +89,54 @@ async function driveSession(
88
89
  await new Promise((r) => setTimeout(r, 0));
89
90
 
90
91
  const result = await session.waitForCompletion();
91
- return { emitted, result };
92
+ return { emitted, result, serverCloseCalls: () => closeServer.mock.calls.length };
93
+ }
94
+
95
+ async function inspectSessionBeforeIdle(
96
+ cfg: ProviderSessionConfig,
97
+ inspect: () => Promise<void>,
98
+ ): Promise<void> {
99
+ const fakeSessionId = "sess-abc-123";
100
+ let releaseIdle!: () => void;
101
+ const idleReleased = new Promise<void>((resolve) => {
102
+ releaseIdle = resolve;
103
+ });
104
+
105
+ const fakeClient = {
106
+ session: {
107
+ create: async () => ({ data: { id: fakeSessionId }, error: undefined }),
108
+ prompt: async (args: unknown) => {
109
+ lastPromptArgs = args;
110
+ return { data: {}, error: undefined };
111
+ },
112
+ },
113
+ event: {
114
+ subscribe: async () => ({
115
+ stream: (async function* (): AsyncGenerator<OpencodeEvent> {
116
+ await idleReleased;
117
+ yield { type: "session.idle", properties: { sessionID: fakeSessionId } };
118
+ })(),
119
+ }),
120
+ },
121
+ };
122
+
123
+ const fakeServer = { url: "http://127.0.0.1:12345", close: mock(() => {}) };
124
+
125
+ mock.module("@opencode-ai/sdk", () => ({
126
+ createOpencode: async (opts: unknown) => {
127
+ lastCreateOpencodeConfig = opts;
128
+ return { client: fakeClient, server: fakeServer };
129
+ },
130
+ }));
131
+
132
+ const { OpencodeAdapter } = await import("../providers/opencode-adapter");
133
+ const adapter = new OpencodeAdapter();
134
+ const session = await adapter.createSession(cfg);
135
+ session.onEvent(() => {});
136
+ await new Promise((r) => setTimeout(r, 0));
137
+ await inspect();
138
+ releaseIdle();
139
+ await session.waitForCompletion();
92
140
  }
93
141
 
94
142
  // ── tests ─────────────────────────────────────────────────────────────────────
@@ -106,7 +154,7 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
106
154
  properties: { sessionID: "sess-abc-123" },
107
155
  },
108
156
  ];
109
- const { emitted, result } = await driveSession(events);
157
+ const { emitted, result, serverCloseCalls } = await driveSession(events);
110
158
 
111
159
  const resultEvent = emitted.find((e) => e.type === "result");
112
160
  expect(resultEvent).toBeDefined();
@@ -118,6 +166,21 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
118
166
  expect(result.isError).toBe(false);
119
167
  expect(result.exitCode).toBe(0);
120
168
  expect(result.sessionId).toBe("sess-abc-123");
169
+ expect(serverCloseCalls()).toBe(1);
170
+ });
171
+
172
+ test("session.idle closes the server and drops later heartbeat events", async () => {
173
+ const events: OpencodeEvent[] = [
174
+ { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
175
+ { type: "server.heartbeat", properties: {} } as OpencodeEvent,
176
+ ];
177
+ const { emitted, serverCloseCalls } = await driveSession(events);
178
+
179
+ expect(serverCloseCalls()).toBe(1);
180
+ const rawLogContents = emitted
181
+ .filter((e): e is Extract<ProviderEvent, { type: "raw_log" }> => e.type === "raw_log")
182
+ .map((e) => e.content);
183
+ expect(rawLogContents.some((content) => content.includes("server.heartbeat"))).toBe(false);
121
184
  });
122
185
 
123
186
  test("session.error → emits error event and fails result", async () => {
@@ -130,7 +193,7 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
130
193
  },
131
194
  },
132
195
  ];
133
- const { emitted, result } = await driveSession(events);
196
+ const { emitted, result, serverCloseCalls } = await driveSession(events);
134
197
 
135
198
  const errorEvent = emitted.find((e) => e.type === "error");
136
199
  expect(errorEvent).toBeDefined();
@@ -140,6 +203,7 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
140
203
  expect(result.isError).toBe(true);
141
204
  expect(result.exitCode).toBe(1);
142
205
  expect(result.failureReason).toContain("provider overloaded");
206
+ expect(serverCloseCalls()).toBe(1);
143
207
  });
144
208
 
145
209
  test("prompt Model not found refreshes OpenRouter cache and retries once", async () => {
@@ -600,42 +664,38 @@ describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
600
664
  });
601
665
 
602
666
  test("per-task agent file is written with system prompt", async () => {
603
- const events: OpencodeEvent[] = [
604
- { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
605
- ];
606
667
  const cwd = `/tmp/opencode-test-agent-${Date.now()}`;
607
668
  await Bun.$`mkdir -p ${cwd}`.quiet();
608
669
  const cfg = testConfig({ taskId: "task-agent-file", systemPrompt: "be a coder", cwd });
609
- await driveSession(events, cfg);
670
+ await inspectSessionBeforeIdle(cfg, async () => {
671
+ const agentFile = Bun.file(join(cwd, ".opencode", "agents", "swarm-task-agent-file.md"));
672
+ const exists = await agentFile.exists();
673
+ expect(exists).toBe(true);
674
+ if (exists) {
675
+ const content = await agentFile.text();
676
+ expect(content).toContain("be a coder");
677
+ }
678
+ });
610
679
 
611
- const agentFile = Bun.file(join(cwd, ".opencode", "agents", "swarm-task-agent-file.md"));
612
- const exists = await agentFile.exists();
613
- expect(exists).toBe(true);
614
- if (exists) {
615
- const content = await agentFile.text();
616
- expect(content).toContain("be a coder");
617
- }
618
680
  // Cleanup
619
681
  await Bun.$`rm -rf ${cwd}`.quiet().nothrow();
620
682
  });
621
683
 
622
684
  test("per-task config file is written as valid JSON", async () => {
623
- const events: OpencodeEvent[] = [
624
- { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
625
- ];
626
685
  const cfg = testConfig({ taskId: "task-cfg-json" });
627
- await driveSession(events, cfg);
686
+ await inspectSessionBeforeIdle(cfg, async () => {
687
+ const configFile = Bun.file("/tmp/opencode-task-cfg-json.json");
688
+ const exists = await configFile.exists();
689
+ expect(exists).toBe(true);
690
+ if (exists) {
691
+ const text = await configFile.text();
692
+ expect(() => JSON.parse(text)).not.toThrow();
693
+ const parsed = JSON.parse(text) as { mcp?: unknown; permission?: unknown };
694
+ expect(parsed.mcp).toBeDefined();
695
+ expect(parsed.permission).toBeDefined();
696
+ }
697
+ });
628
698
 
629
- const configFile = Bun.file("/tmp/opencode-task-cfg-json.json");
630
- const exists = await configFile.exists();
631
- expect(exists).toBe(true);
632
- if (exists) {
633
- const text = await configFile.text();
634
- expect(() => JSON.parse(text)).not.toThrow();
635
- const parsed = JSON.parse(text) as { mcp?: unknown; permission?: unknown };
636
- expect(parsed.mcp).toBeDefined();
637
- expect(parsed.permission).toBeDefined();
638
- }
639
699
  // Cleanup
640
700
  await Bun.$`rm -f /tmp/opencode-task-cfg-json.json`.quiet().nothrow();
641
701
  await Bun.$`rm -rf /tmp/opencode-data-task-cfg-json`.quiet().nothrow();
@@ -0,0 +1,117 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { ensureRepoForTask } from "../commands/runner";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ let tempRoot = "";
12
+
13
+ async function git(cwd: string, args: string[]): Promise<string> {
14
+ const { stdout } = await execFileAsync("git", ["-C", cwd, ...args]);
15
+ return stdout;
16
+ }
17
+
18
+ async function gitRaw(args: string[]): Promise<void> {
19
+ await execFileAsync("git", args);
20
+ }
21
+
22
+ async function commitAll(cwd: string, message: string): Promise<void> {
23
+ await git(cwd, ["add", "."]);
24
+ await git(cwd, ["commit", "-m", message]);
25
+ }
26
+
27
+ async function configureIdentity(cwd: string): Promise<void> {
28
+ await git(cwd, ["config", "user.email", "test@example.com"]);
29
+ await git(cwd, ["config", "user.name", "Test User"]);
30
+ }
31
+
32
+ describe("ensureRepoForTask auto-stash refresh", () => {
33
+ beforeEach(async () => {
34
+ tempRoot = await mkdtemp(join(tmpdir(), "swarm-runner-autostash-"));
35
+ });
36
+
37
+ afterEach(async () => {
38
+ if (tempRoot) {
39
+ await rm(tempRoot, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test("stashes dirty work with a swarm-autostash name before refreshing from origin", async () => {
44
+ const remotePath = join(tempRoot, "remote.git");
45
+ const upstreamPath = join(tempRoot, "upstream");
46
+ const clonePath = join(tempRoot, "clone");
47
+
48
+ await gitRaw(["init", "--bare", remotePath]);
49
+ await mkdir(upstreamPath);
50
+ await git(upstreamPath, ["init", "-b", "main"]);
51
+ await configureIdentity(upstreamPath);
52
+ await writeFile(join(upstreamPath, "README.md"), "initial\n");
53
+ await commitAll(upstreamPath, "initial commit");
54
+ await git(upstreamPath, ["remote", "add", "origin", remotePath]);
55
+ await git(upstreamPath, ["push", "-u", "origin", "main"]);
56
+
57
+ await gitRaw(["clone", "--branch", "main", remotePath, clonePath]);
58
+ await configureIdentity(clonePath);
59
+ await writeFile(join(clonePath, "README.md"), "local dirty change\n");
60
+ await writeFile(join(clonePath, "untracked.txt"), "local untracked\n");
61
+
62
+ await writeFile(join(upstreamPath, "remote.txt"), "remote change\n");
63
+ await commitAll(upstreamPath, "remote update");
64
+ await git(upstreamPath, ["push", "origin", "main"]);
65
+
66
+ const result = await ensureRepoForTask(
67
+ { url: remotePath, name: "repo", clonePath, defaultBranch: "main" },
68
+ "test",
69
+ );
70
+
71
+ expect(result.warning).toBeNull();
72
+ expect(result.autoStashes).toHaveLength(1);
73
+ expect(result.autoStashes[0]?.ref).toMatch(/^stash@\{\d+\}$/);
74
+ expect(result.autoStashes[0]?.message).toContain("swarm-autostash main ");
75
+ expect(await readFile(join(clonePath, "remote.txt"), "utf8")).toBe("remote change\n");
76
+ expect((await git(clonePath, ["status", "--porcelain"])).trim()).toBe("");
77
+
78
+ const stashList = await git(clonePath, ["stash", "list"]);
79
+ expect(stashList).toContain("swarm-autostash main ");
80
+ expect(stashList).toContain("On main:");
81
+ });
82
+
83
+ test("merges a clean divergent checkout with origin without hard reset", async () => {
84
+ const remotePath = join(tempRoot, "remote.git");
85
+ const upstreamPath = join(tempRoot, "upstream");
86
+ const clonePath = join(tempRoot, "clone");
87
+
88
+ await gitRaw(["init", "--bare", remotePath]);
89
+ await mkdir(upstreamPath);
90
+ await git(upstreamPath, ["init", "-b", "main"]);
91
+ await configureIdentity(upstreamPath);
92
+ await writeFile(join(upstreamPath, "README.md"), "initial\n");
93
+ await commitAll(upstreamPath, "initial commit");
94
+ await git(upstreamPath, ["remote", "add", "origin", remotePath]);
95
+ await git(upstreamPath, ["push", "-u", "origin", "main"]);
96
+
97
+ await gitRaw(["clone", "--branch", "main", remotePath, clonePath]);
98
+ await configureIdentity(clonePath);
99
+ await writeFile(join(clonePath, "local.txt"), "local commit\n");
100
+ await commitAll(clonePath, "local commit");
101
+
102
+ await writeFile(join(upstreamPath, "remote.txt"), "remote commit\n");
103
+ await commitAll(upstreamPath, "remote commit");
104
+ await git(upstreamPath, ["push", "origin", "main"]);
105
+
106
+ const result = await ensureRepoForTask(
107
+ { url: remotePath, name: "repo", clonePath, defaultBranch: "main" },
108
+ "test",
109
+ );
110
+
111
+ expect(result.warning).toBeNull();
112
+ expect(result.autoStashes).toEqual([]);
113
+ expect(await readFile(join(clonePath, "local.txt"), "utf8")).toBe("local commit\n");
114
+ expect(await readFile(join(clonePath, "remote.txt"), "utf8")).toBe("remote commit\n");
115
+ expect((await git(clonePath, ["status", "--porcelain"])).trim()).toBe("");
116
+ });
117
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildRequesterProfilePrompt } from "../commands/runner";
3
+
4
+ describe("runner requester profile prompt", () => {
5
+ test("omits requester profile when no role or notes are set", async () => {
6
+ await expect(
7
+ buildRequesterProfilePrompt({ name: "Taras", email: "t@example.com" }),
8
+ ).resolves.toBe("");
9
+ });
10
+
11
+ test("formats requester role and free-text notes", async () => {
12
+ const prompt = await buildRequesterProfilePrompt({
13
+ name: "Taras",
14
+ email: "t@example.com",
15
+ role: "CEO",
16
+ notes: "Lead with the answer; keep updates terse.",
17
+ });
18
+
19
+ expect(prompt).toContain("## Requester Profile");
20
+ expect(prompt).toContain("This task was requested by Taras (CEO).");
21
+ expect(prompt).toContain("Their stated notes for how you should respond and act:");
22
+ expect(prompt).toContain("Lead with the answer; keep updates terse.");
23
+ expect(prompt).toContain("where it doesn't conflict with correctness or your operating rules");
24
+ });
25
+ });
@@ -83,7 +83,7 @@ describe("refreshSkillsIfChanged", () => {
83
83
  function makeCtx(): SkillsRefreshContext {
84
84
  return {
85
85
  apiUrl: baseUrl,
86
- swarmUrl: baseUrl,
86
+ swarmUrl: "app.agent-swarm.dev",
87
87
  apiKey: "test-key",
88
88
  agentId: "agent-1",
89
89
  role: "worker",
@@ -0,0 +1,90 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { registerSwarmXTool } from "../tools/swarm-x";
4
+ import { clearVolatileSecretsForTesting } from "../utils/secret-scrubber";
5
+
6
+ type RegisteredTool = {
7
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
8
+ };
9
+
10
+ const originalFetch = globalThis.fetch;
11
+ const originalComposioKey = process.env.COMPOSIO_API_KEY;
12
+
13
+ function jsonResponse(body: unknown, status = 200): Response {
14
+ return new Response(JSON.stringify(body), {
15
+ status,
16
+ headers: { "Content-Type": "application/json" },
17
+ });
18
+ }
19
+
20
+ function buildTool() {
21
+ const server = new McpServer({ name: "swarm-x-test", version: "1.0.0" });
22
+ registerSwarmXTool(server);
23
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
24
+ ._registeredTools;
25
+ const tool = registered.swarm_x;
26
+ if (!tool) throw new Error("swarm_x tool not registered");
27
+ return tool;
28
+ }
29
+
30
+ afterEach(() => {
31
+ globalThis.fetch = originalFetch;
32
+ if (originalComposioKey === undefined) delete process.env.COMPOSIO_API_KEY;
33
+ else process.env.COMPOSIO_API_KEY = originalComposioKey;
34
+ clearVolatileSecretsForTesting();
35
+ });
36
+
37
+ describe("swarm_x MCP tool", () => {
38
+ test("routes composio requests with server-side auth and scrubbed output", async () => {
39
+ process.env.COMPOSIO_API_KEY = "ck_tool_secret_value";
40
+ const fetchMock = mock(async (url: string | URL | Request, init?: RequestInit) => {
41
+ expect(String(url)).toBe("https://backend.composio.dev/api/v3.1/tools?limit=1");
42
+ expect(init?.method).toBe("GET");
43
+ expect((init?.headers as Record<string, string>)["x-api-key"]).toBe("ck_tool_secret_value");
44
+ return jsonResponse({ ok: true, token: "ck_tool_secret_value" });
45
+ });
46
+ globalThis.fetch = fetchMock;
47
+
48
+ const tool = buildTool();
49
+ const result = (await tool.handler(
50
+ {
51
+ target: "composio",
52
+ method: "GET",
53
+ path: "/tools",
54
+ query: { limit: 1 },
55
+ },
56
+ { sessionId: "s", requestInfo: { headers: {} } },
57
+ )) as {
58
+ isError?: boolean;
59
+ structuredContent: { ok: boolean; response: unknown; responseText: string };
60
+ };
61
+
62
+ expect(fetchMock).toHaveBeenCalledTimes(1);
63
+ expect(result.isError).toBe(false);
64
+ expect(result.structuredContent.ok).toBe(true);
65
+ expect(JSON.stringify(result.structuredContent.response)).toContain(
66
+ "[REDACTED:COMPOSIO_API_KEY]",
67
+ );
68
+ expect(result.structuredContent.responseText).not.toContain("ck_tool_secret_value");
69
+ });
70
+
71
+ test("rejects absolute composio paths", async () => {
72
+ process.env.COMPOSIO_API_KEY = "ck_tool_secret_value";
73
+ const fetchMock = mock(async () => jsonResponse({ ok: true }));
74
+ globalThis.fetch = fetchMock;
75
+
76
+ const tool = buildTool();
77
+ const result = (await tool.handler(
78
+ {
79
+ target: "composio",
80
+ method: "GET",
81
+ path: "https://evil.example/tools",
82
+ },
83
+ { sessionId: "s", requestInfo: { headers: {} } },
84
+ )) as { isError?: boolean; structuredContent: { message: string } };
85
+
86
+ expect(result.isError).toBe(true);
87
+ expect(result.structuredContent.message).toContain("endpoint must be a Composio API path");
88
+ expect(fetchMock).not.toHaveBeenCalled();
89
+ });
90
+ });