@desplega.ai/agent-swarm 1.71.2 → 1.72.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.
- package/README.md +3 -2
- package/openapi.json +994 -62
- package/package.json +2 -1
- package/src/be/budget-admission.ts +121 -0
- package/src/be/budget-refusal-notify.ts +145 -0
- package/src/be/db.ts +488 -5
- package/src/be/migrations/044_provider_meta.sql +2 -0
- package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
- package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
- package/src/cli.tsx +22 -1
- package/src/commands/claude-managed-setup.ts +687 -0
- package/src/commands/codex-login.ts +1 -1
- package/src/commands/runner.ts +175 -28
- package/src/commands/templates.ts +10 -6
- package/src/http/budgets.ts +219 -0
- package/src/http/index.ts +6 -0
- package/src/http/integrations.ts +134 -0
- package/src/http/poll.ts +161 -3
- package/src/http/pricing.ts +245 -0
- package/src/http/session-data.ts +54 -6
- package/src/http/tasks.ts +23 -2
- package/src/prompts/base-prompt.ts +103 -73
- package/src/prompts/session-templates.ts +43 -0
- package/src/providers/claude-adapter.ts +3 -1
- package/src/providers/claude-managed-adapter.ts +871 -0
- package/src/providers/claude-managed-models.ts +117 -0
- package/src/providers/claude-managed-swarm-events.ts +77 -0
- package/src/providers/codex-adapter.ts +3 -1
- package/src/providers/codex-skill-resolver.ts +10 -0
- package/src/providers/codex-swarm-events.ts +20 -161
- package/src/providers/devin-adapter.ts +894 -0
- package/src/providers/devin-api.ts +207 -0
- package/src/providers/devin-playbooks.ts +91 -0
- package/src/providers/devin-skill-resolver.ts +113 -0
- package/src/providers/index.ts +10 -1
- package/src/providers/pi-mono-adapter.ts +3 -1
- package/src/providers/swarm-events-shared.ts +262 -0
- package/src/providers/types.ts +26 -1
- package/src/tests/base-prompt.test.ts +199 -0
- package/src/tests/budget-admission.test.ts +339 -0
- package/src/tests/budget-claim-gate.test.ts +288 -0
- package/src/tests/budget-refusal-notification.test.ts +324 -0
- package/src/tests/budgets-routes.test.ts +331 -0
- package/src/tests/claude-managed-adapter.test.ts +1301 -0
- package/src/tests/claude-managed-setup.test.ts +325 -0
- package/src/tests/devin-adapter.test.ts +677 -0
- package/src/tests/devin-api.test.ts +339 -0
- package/src/tests/integrations-http.test.ts +211 -0
- package/src/tests/migration-046-budgets.test.ts +327 -0
- package/src/tests/pricing-routes.test.ts +315 -0
- package/src/tests/prompt-template-remaining.test.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +2 -2
- package/src/tests/provider-adapter.test.ts +1 -1
- package/src/tests/runner-budget-refused.test.ts +271 -0
- package/src/tests/session-costs-codex-recompute.test.ts +386 -0
- package/src/tools/poll-task.ts +13 -2
- package/src/tools/task-action.ts +92 -2
- package/src/tools/templates.ts +29 -0
- package/src/types.ts +116 -0
- package/src/utils/budget-backoff.ts +34 -0
- package/src/utils/credentials.ts +4 -0
- package/src/utils/provider-metadata.ts +9 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
resolveClaudeManagedSetupConfig,
|
|
5
|
+
runClaudeManagedSetup,
|
|
6
|
+
runClaudeManagedSetupFlow,
|
|
7
|
+
} from "../commands/claude-managed-setup";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mocked-fetch / mocked-Anthropic-SDK tests for `claude-managed-setup`.
|
|
11
|
+
*
|
|
12
|
+
* Plan reference: Phase 2 §Automated QA — assert the setup flow hits
|
|
13
|
+
* environments.create, skills.create (×N), agents.create, then PUT /api/config
|
|
14
|
+
* for each ID, and is idempotent on re-run.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ─── Mock factories ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function makeMockClient(overrides: Record<string, unknown> = {}) {
|
|
20
|
+
const environmentsCreate = mock(async () => ({ id: "env_test_123" }));
|
|
21
|
+
const skillsCreate = mock(async () => ({ id: "skill_test_abc" }));
|
|
22
|
+
const agentsCreate = mock(async () => ({ id: "agent_test_xyz" }));
|
|
23
|
+
|
|
24
|
+
const client = {
|
|
25
|
+
beta: {
|
|
26
|
+
environments: { create: environmentsCreate },
|
|
27
|
+
skills: { create: skillsCreate },
|
|
28
|
+
agents: { create: agentsCreate },
|
|
29
|
+
},
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
return { client, environmentsCreate, skillsCreate, agentsCreate };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const baseConfig = {
|
|
36
|
+
apiUrl: "http://localhost:3013",
|
|
37
|
+
apiKey: "123123",
|
|
38
|
+
anthropicApiKey: "sk-ant-test",
|
|
39
|
+
mcpBaseUrl: "https://swarm.example.com",
|
|
40
|
+
agentModel: "claude-sonnet-4-6",
|
|
41
|
+
force: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const fakeSkillFiles = [
|
|
45
|
+
{ slug: "work-on-task", absPath: "/x/work-on-task.md", content: "# work-on-task\n" },
|
|
46
|
+
{ slug: "create-pr", absPath: "/x/create-pr.md", content: "# create-pr\n" },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
describe("runClaudeManagedSetupFlow — happy path", () => {
|
|
52
|
+
test("calls environments.create, skills.create (xN), agents.create, then upserts 3 configs", async () => {
|
|
53
|
+
const { client, environmentsCreate, skillsCreate, agentsCreate } = makeMockClient();
|
|
54
|
+
|
|
55
|
+
const fetchConfig = mock(async () => null);
|
|
56
|
+
const upsert = mock(async () => undefined);
|
|
57
|
+
const loadSkills = mock(async () => fakeSkillFiles);
|
|
58
|
+
const uploadOne = mock(async (_c: unknown, slug: string) => `skill_${slug}`);
|
|
59
|
+
const log = mock((_msg: string) => undefined);
|
|
60
|
+
|
|
61
|
+
const result = await runClaudeManagedSetupFlow(baseConfig, {
|
|
62
|
+
// biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
|
|
63
|
+
client: client as any,
|
|
64
|
+
fetchConfig,
|
|
65
|
+
upsert,
|
|
66
|
+
loadSkills,
|
|
67
|
+
uploadOne,
|
|
68
|
+
log,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.alreadyConfigured).toBe(false);
|
|
72
|
+
expect(result.agentId).toBe("agent_test_xyz");
|
|
73
|
+
expect(result.environmentId).toBe("env_test_123");
|
|
74
|
+
expect(result.skillIds).toEqual(["skill_work-on-task", "skill_create-pr"]);
|
|
75
|
+
|
|
76
|
+
expect(environmentsCreate).toHaveBeenCalledTimes(1);
|
|
77
|
+
const envCallArgs = environmentsCreate.mock.calls[0]?.[0] as {
|
|
78
|
+
name: string;
|
|
79
|
+
config: { type: string; networking: { type: string } };
|
|
80
|
+
};
|
|
81
|
+
expect(envCallArgs.name).toBe("swarm-worker-env");
|
|
82
|
+
expect(envCallArgs.config.type).toBe("cloud");
|
|
83
|
+
expect(envCallArgs.config.networking.type).toBe("unrestricted");
|
|
84
|
+
|
|
85
|
+
expect(uploadOne).toHaveBeenCalledTimes(fakeSkillFiles.length);
|
|
86
|
+
|
|
87
|
+
expect(agentsCreate).toHaveBeenCalledTimes(1);
|
|
88
|
+
const agentCallArgs = agentsCreate.mock.calls[0]?.[0] as {
|
|
89
|
+
name: string;
|
|
90
|
+
model: string;
|
|
91
|
+
tools: Array<{ type: string }>;
|
|
92
|
+
skills: Array<{ type: string; skill_id: string }>;
|
|
93
|
+
mcp_servers: Array<{ name: string; type: string; url: string }>;
|
|
94
|
+
};
|
|
95
|
+
expect(agentCallArgs.name).toBe("swarm-worker");
|
|
96
|
+
expect(agentCallArgs.model).toBe("claude-sonnet-4-6");
|
|
97
|
+
expect(agentCallArgs.tools[0]?.type).toBe("agent_toolset_20260401");
|
|
98
|
+
expect(agentCallArgs.skills.map((s) => s.skill_id)).toEqual([
|
|
99
|
+
"skill_work-on-task",
|
|
100
|
+
"skill_create-pr",
|
|
101
|
+
]);
|
|
102
|
+
expect(agentCallArgs.mcp_servers[0]?.url).toBe("https://swarm.example.com/mcp");
|
|
103
|
+
|
|
104
|
+
// Three upserts: managed_agent_id, managed_environment_id, anthropic_api_key.
|
|
105
|
+
expect(upsert).toHaveBeenCalledTimes(3);
|
|
106
|
+
const upsertedKeys = upsert.mock.calls.map((c) => (c[2] as { key: string }).key);
|
|
107
|
+
expect(new Set(upsertedKeys)).toEqual(
|
|
108
|
+
new Set(["managed_agent_id", "managed_environment_id", "anthropic_api_key"]),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Sensitive flags: anthropic_api_key isSecret=true; the IDs are not secret.
|
|
112
|
+
const apiKeyEntry = upsert.mock.calls.find(
|
|
113
|
+
(c) => (c[2] as { key: string }).key === "anthropic_api_key",
|
|
114
|
+
);
|
|
115
|
+
expect((apiKeyEntry?.[2] as { isSecret?: boolean }).isSecret).toBe(true);
|
|
116
|
+
|
|
117
|
+
// Sky check skillsCreate was not called directly — uploadOne handles it
|
|
118
|
+
expect(skillsCreate).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("strips trailing slash from mcpBaseUrl when building the MCP server URL", async () => {
|
|
122
|
+
const { client, agentsCreate } = makeMockClient();
|
|
123
|
+
await runClaudeManagedSetupFlow(
|
|
124
|
+
{ ...baseConfig, mcpBaseUrl: "https://swarm.example.com/" },
|
|
125
|
+
{
|
|
126
|
+
// biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
|
|
127
|
+
client: client as any,
|
|
128
|
+
fetchConfig: mock(async () => null),
|
|
129
|
+
upsert: mock(async () => undefined),
|
|
130
|
+
loadSkills: mock(async () => []),
|
|
131
|
+
uploadOne: mock(async () => null),
|
|
132
|
+
log: mock(() => undefined),
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
const agentCallArgs = agentsCreate.mock.calls[0]?.[0] as {
|
|
136
|
+
mcp_servers: Array<{ url: string }>;
|
|
137
|
+
};
|
|
138
|
+
expect(agentCallArgs.mcp_servers[0]?.url).toBe("https://swarm.example.com/mcp");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("runClaudeManagedSetupFlow — idempotent re-run", () => {
|
|
143
|
+
test("short-circuits with already-configured when managed_agent_id exists in swarm_config", async () => {
|
|
144
|
+
const { client, environmentsCreate, agentsCreate } = makeMockClient();
|
|
145
|
+
const fetchConfig = mock(async (_url: string, _key: string, key: string) => {
|
|
146
|
+
if (key === "managed_agent_id") {
|
|
147
|
+
return {
|
|
148
|
+
scope: "global",
|
|
149
|
+
key,
|
|
150
|
+
value: "agent_already_there",
|
|
151
|
+
isSecret: false,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (key === "managed_environment_id") {
|
|
155
|
+
return {
|
|
156
|
+
scope: "global",
|
|
157
|
+
key,
|
|
158
|
+
value: "env_already_there",
|
|
159
|
+
isSecret: false,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
});
|
|
164
|
+
const upsert = mock(async () => undefined);
|
|
165
|
+
const loadSkills = mock(async () => fakeSkillFiles);
|
|
166
|
+
const uploadOne = mock(async () => null);
|
|
167
|
+
|
|
168
|
+
const result = await runClaudeManagedSetupFlow(baseConfig, {
|
|
169
|
+
// biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
|
|
170
|
+
client: client as any,
|
|
171
|
+
fetchConfig,
|
|
172
|
+
upsert,
|
|
173
|
+
loadSkills,
|
|
174
|
+
uploadOne,
|
|
175
|
+
log: mock(() => undefined),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(result.alreadyConfigured).toBe(true);
|
|
179
|
+
expect(result.agentId).toBe("agent_already_there");
|
|
180
|
+
expect(result.environmentId).toBe("env_already_there");
|
|
181
|
+
|
|
182
|
+
// No Anthropic API calls.
|
|
183
|
+
expect(environmentsCreate).not.toHaveBeenCalled();
|
|
184
|
+
expect(agentsCreate).not.toHaveBeenCalled();
|
|
185
|
+
// No swarm_config writes — IDs already there.
|
|
186
|
+
expect(upsert).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("--force bypasses idempotency check", async () => {
|
|
190
|
+
const { client, environmentsCreate, agentsCreate } = makeMockClient();
|
|
191
|
+
const fetchConfig = mock(async () => ({
|
|
192
|
+
scope: "global",
|
|
193
|
+
key: "managed_agent_id",
|
|
194
|
+
value: "agent_already_there",
|
|
195
|
+
isSecret: false,
|
|
196
|
+
}));
|
|
197
|
+
const upsert = mock(async () => undefined);
|
|
198
|
+
|
|
199
|
+
await runClaudeManagedSetupFlow(
|
|
200
|
+
{ ...baseConfig, force: true },
|
|
201
|
+
{
|
|
202
|
+
// biome-ignore lint/suspicious/noExplicitAny: fake client subset for mock
|
|
203
|
+
client: client as any,
|
|
204
|
+
fetchConfig,
|
|
205
|
+
upsert,
|
|
206
|
+
loadSkills: mock(async () => []),
|
|
207
|
+
uploadOne: mock(async () => null),
|
|
208
|
+
log: mock(() => undefined),
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(environmentsCreate).toHaveBeenCalledTimes(1);
|
|
213
|
+
expect(agentsCreate).toHaveBeenCalledTimes(1);
|
|
214
|
+
expect(upsert).toHaveBeenCalledTimes(3);
|
|
215
|
+
// fetchConfig should NOT be called when --force.
|
|
216
|
+
expect(fetchConfig).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("resolveClaudeManagedSetupConfig", () => {
|
|
221
|
+
test("uses defaults + env vars without prompts when not interactive", async () => {
|
|
222
|
+
const promptSecret = mock(async () => {
|
|
223
|
+
throw new Error("should not prompt");
|
|
224
|
+
});
|
|
225
|
+
const result = await resolveClaudeManagedSetupConfig([], {
|
|
226
|
+
env: {
|
|
227
|
+
ANTHROPIC_API_KEY: "sk-ant-from-env",
|
|
228
|
+
MCP_BASE_URL: "https://example.com",
|
|
229
|
+
API_KEY: "key123",
|
|
230
|
+
},
|
|
231
|
+
isInteractive: false,
|
|
232
|
+
promptSecret,
|
|
233
|
+
});
|
|
234
|
+
expect(result.anthropicApiKey).toBe("sk-ant-from-env");
|
|
235
|
+
expect(result.mcpBaseUrl).toBe("https://example.com");
|
|
236
|
+
expect(result.apiKey).toBe("key123");
|
|
237
|
+
expect(result.apiUrl).toBe("https://example.com");
|
|
238
|
+
expect(result.agentModel).toBe("claude-sonnet-4-6");
|
|
239
|
+
expect(promptSecret).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("rejects http:// MCP_BASE_URL fail-fast", async () => {
|
|
243
|
+
await expect(
|
|
244
|
+
resolveClaudeManagedSetupConfig([], {
|
|
245
|
+
env: { ANTHROPIC_API_KEY: "sk-ant-x", MCP_BASE_URL: "http://insecure.local" },
|
|
246
|
+
isInteractive: false,
|
|
247
|
+
}),
|
|
248
|
+
).rejects.toThrow(/must start with https/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("rejects missing MCP_BASE_URL", async () => {
|
|
252
|
+
await expect(
|
|
253
|
+
resolveClaudeManagedSetupConfig([], {
|
|
254
|
+
env: { ANTHROPIC_API_KEY: "sk-ant-x" },
|
|
255
|
+
isInteractive: false,
|
|
256
|
+
}),
|
|
257
|
+
).rejects.toThrow(/MCP_BASE_URL is not set/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("--force flag is parsed", async () => {
|
|
261
|
+
const result = await resolveClaudeManagedSetupConfig(["--force"], {
|
|
262
|
+
env: {
|
|
263
|
+
ANTHROPIC_API_KEY: "sk-ant-x",
|
|
264
|
+
MCP_BASE_URL: "https://example.com",
|
|
265
|
+
},
|
|
266
|
+
isInteractive: false,
|
|
267
|
+
});
|
|
268
|
+
expect(result.force).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("runClaudeManagedSetup — entry point", () => {
|
|
273
|
+
test("--help prints usage and returns without invoking the flow", async () => {
|
|
274
|
+
const log = mock((_msg: string) => undefined);
|
|
275
|
+
const errorFn = mock((_msg: string) => undefined);
|
|
276
|
+
const exit = mock((_code: number) => {
|
|
277
|
+
throw new Error("exit was called");
|
|
278
|
+
});
|
|
279
|
+
const flow = mock(async () => {
|
|
280
|
+
throw new Error("flow should not be called");
|
|
281
|
+
});
|
|
282
|
+
const resolveConfig = mock(async () => {
|
|
283
|
+
throw new Error("resolveConfig should not be called");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await runClaudeManagedSetup(["--help"], {
|
|
287
|
+
log,
|
|
288
|
+
error: errorFn,
|
|
289
|
+
exit,
|
|
290
|
+
flow,
|
|
291
|
+
resolveConfig,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(flow).not.toHaveBeenCalled();
|
|
295
|
+
expect(resolveConfig).not.toHaveBeenCalled();
|
|
296
|
+
expect(exit).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("on error, calls error() + exit(1) without throwing", async () => {
|
|
300
|
+
const log = mock((_msg: string) => undefined);
|
|
301
|
+
const errorMessages: string[] = [];
|
|
302
|
+
const errorFn = (msg: string) => {
|
|
303
|
+
errorMessages.push(msg);
|
|
304
|
+
};
|
|
305
|
+
let exitCode: number | null = null;
|
|
306
|
+
const exit = (code: number) => {
|
|
307
|
+
exitCode = code;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
await runClaudeManagedSetup([], {
|
|
311
|
+
resolveConfig: async () => {
|
|
312
|
+
throw new Error("resolve fail");
|
|
313
|
+
},
|
|
314
|
+
flow: async () => {
|
|
315
|
+
throw new Error("flow should not be called");
|
|
316
|
+
},
|
|
317
|
+
log,
|
|
318
|
+
error: errorFn,
|
|
319
|
+
exit,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(exitCode).toBe(1);
|
|
323
|
+
expect(errorMessages.join("\n")).toMatch(/resolve fail/);
|
|
324
|
+
});
|
|
325
|
+
});
|