@desplega.ai/agent-swarm 1.90.0 → 1.92.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 +2 -1
- package/openapi.json +803 -150
- package/package.json +5 -5
- package/src/artifact-sdk/server.ts +2 -1
- package/src/be/db.ts +337 -1
- package/src/be/memory/providers/sqlite-store.ts +6 -1
- package/src/be/memory/types.ts +1 -0
- package/src/be/migrations/083_script_workflows.sql +51 -0
- package/src/be/modelsdev-cache.json +42352 -38595
- package/src/be/scripts/typecheck.ts +181 -1
- package/src/be/seed-scripts/catalog/compound-insights.ts +398 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +911 -0
- package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
- package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
- package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
- package/src/be/seed-scripts/catalog/tool-usage.ts +59 -0
- package/src/be/seed-scripts/index.ts +54 -0
- package/src/be/seed-skills/index.ts +7 -0
- package/src/be/swarm-config-guard.ts +17 -0
- package/src/commands/artifact.ts +3 -2
- package/src/commands/profile-sync.ts +310 -0
- package/src/commands/runner.ts +134 -3
- package/src/hooks/hook.ts +32 -9
- package/src/http/db-query.ts +20 -5
- package/src/http/index.ts +57 -0
- package/src/http/integrations.ts +6 -1
- package/src/http/mcp-bridge.ts +117 -0
- package/src/http/mcp-oauth.ts +97 -39
- package/src/http/memory.ts +5 -2
- package/src/http/openapi.ts +2 -2
- package/src/http/pages-public.ts +10 -11
- package/src/http/pages.ts +7 -11
- package/src/http/script-runs.ts +555 -0
- package/src/http/scripts.ts +24 -1
- package/src/http/utils.ts +11 -4
- package/src/jira/app.ts +2 -3
- package/src/jira/webhook-lifecycle.ts +2 -1
- package/src/linear/app.ts +2 -3
- package/src/prompts/session-templates.ts +24 -4
- package/src/providers/claude-adapter.ts +86 -13
- package/src/script-workflows/executor.ts +110 -0
- package/src/script-workflows/harness.ts +73 -0
- package/src/script-workflows/label-lint.ts +51 -0
- package/src/script-workflows/limits.ts +22 -0
- package/src/script-workflows/supervisor.ts +139 -0
- package/src/script-workflows/workflow-ctx.ts +205 -0
- package/src/scripts-runtime/executors/native.ts +1 -0
- package/src/scripts-runtime/sdk-allowlist.ts +124 -0
- package/src/scripts-runtime/swarm-sdk.ts +198 -3
- package/src/scripts-runtime/types/stdlib.d.ts +287 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +287 -0
- package/src/server.ts +2 -0
- package/src/slack/handlers.ts +11 -4
- package/src/slack/message-text.ts +98 -0
- package/src/slack/thread-buffer.ts +5 -3
- package/src/tests/claude-adapter-binary.test.ts +147 -4
- package/src/tests/claude-adapter-otel.test.ts +85 -1
- package/src/tests/db-query.test.ts +28 -0
- package/src/tests/error-tracker.test.ts +121 -0
- package/src/tests/harness-provider-resolution.test.ts +33 -0
- package/src/tests/hook-registration-nudge.test.ts +69 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
- package/src/tests/mcp-tools.test.ts +6 -0
- package/src/tests/pages-public-html.test.ts +41 -0
- package/src/tests/pages-public-json-redirect.test.ts +37 -2
- package/src/tests/profile-sync.test.ts +282 -0
- package/src/tests/prompt-template-session.test.ts +34 -5
- package/src/tests/script-runs-http.test.ts +278 -0
- package/src/tests/script-workflows-label-lint.test.ts +43 -0
- package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
- package/src/tests/scripts-mcp-e2e.test.ts +49 -2
- package/src/tests/scripts-runtime.test.ts +33 -0
- package/src/tests/seed-scripts.test.ts +347 -2
- package/src/tests/slack-message-text.test.ts +250 -0
- package/src/tests/system-default-skills.test.ts +40 -0
- package/src/tools/create-metric.ts +2 -3
- package/src/tools/create-page.ts +3 -6
- package/src/tools/db-query.ts +16 -6
- package/src/tools/memory-rate.ts +2 -1
- package/src/tools/memory-search.ts +1 -0
- package/src/tools/register-kapso-number.ts +2 -4
- package/src/tools/request-human-input.ts +2 -1
- package/src/tools/script-common.ts +2 -4
- package/src/tools/script-run.ts +7 -0
- package/src/tools/script-runs.ts +123 -0
- package/src/tools/slack-read.ts +12 -3
- package/src/tools/tool-config.ts +4 -1
- package/src/types.ts +52 -0
- package/src/utils/constants.ts +58 -8
- package/src/utils/error-tracker.ts +40 -1
- package/src/utils/internal-ai/complete-structured.ts +10 -4
- package/src/workflows/executors/raw-llm.ts +76 -59
- package/templates/skills/pages/content.md +205 -55
- package/templates/skills/script-workflows/config.json +14 -0
- package/templates/skills/script-workflows/content.md +68 -0
- package/templates/skills/swarm-scripts/content.md +45 -7
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildIdentityPayload,
|
|
4
|
+
CLAUDE_MD_PATH,
|
|
5
|
+
collectProfilePayloads,
|
|
6
|
+
extractSetupScriptContent,
|
|
7
|
+
type FileReader,
|
|
8
|
+
IDENTITY_MD_PATH,
|
|
9
|
+
postProfileUpdate,
|
|
10
|
+
resolveClaudeMdPath,
|
|
11
|
+
SETUP_SCRIPT_PATH,
|
|
12
|
+
SOUL_MD_PATH,
|
|
13
|
+
syncProfileFilesToServer,
|
|
14
|
+
TOOLS_MD_PATH,
|
|
15
|
+
WORKSPACE_CLAUDE_MD_PATH,
|
|
16
|
+
} from "../commands/profile-sync";
|
|
17
|
+
|
|
18
|
+
const MARKER_START = "# === Agent-managed setup (from DB) ===";
|
|
19
|
+
const MARKER_END = "# === End agent-managed setup ===";
|
|
20
|
+
|
|
21
|
+
// A SOUL/IDENTITY body long enough to clear the 500-char min-length guard.
|
|
22
|
+
const LONG = "x".repeat(600);
|
|
23
|
+
|
|
24
|
+
describe("extractSetupScriptContent (marker extraction)", () => {
|
|
25
|
+
test("extracts ONLY the content between the agent-managed markers", () => {
|
|
26
|
+
const raw = [
|
|
27
|
+
"#!/bin/bash",
|
|
28
|
+
"echo operator-prelude",
|
|
29
|
+
MARKER_START,
|
|
30
|
+
"export FOO=bar",
|
|
31
|
+
'echo "agent line"',
|
|
32
|
+
MARKER_END,
|
|
33
|
+
"echo operator-postlude",
|
|
34
|
+
].join("\n");
|
|
35
|
+
|
|
36
|
+
expect(extractSetupScriptContent(raw)).toBe('export FOO=bar\necho "agent line"');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("strips a leading shebang when no markers are present", () => {
|
|
40
|
+
const raw = '#!/bin/bash\necho "whole file is agent-managed"';
|
|
41
|
+
expect(extractSetupScriptContent(raw)).toBe('echo "whole file is agent-managed"');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns null for an empty / whitespace-only file", () => {
|
|
45
|
+
expect(extractSetupScriptContent("")).toBeNull();
|
|
46
|
+
expect(extractSetupScriptContent(" \n\t ")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns null when the marker section is empty", () => {
|
|
50
|
+
const raw = `prelude\n${MARKER_START}\n \n${MARKER_END}\npostlude`;
|
|
51
|
+
expect(extractSetupScriptContent(raw)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns null when content exceeds the max length", () => {
|
|
55
|
+
const raw = `${MARKER_START}\n${"a".repeat(65537)}\n${MARKER_END}`;
|
|
56
|
+
expect(extractSetupScriptContent(raw)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("buildIdentityPayload (min-length guard)", () => {
|
|
61
|
+
test("includes SOUL/IDENTITY only when they clear the 500-char minimum", () => {
|
|
62
|
+
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
63
|
+
try {
|
|
64
|
+
const ok = buildIdentityPayload({ soulMd: LONG, identityMd: LONG });
|
|
65
|
+
expect(ok.soulMd).toBe(LONG);
|
|
66
|
+
expect(ok.identityMd).toBe(LONG);
|
|
67
|
+
|
|
68
|
+
const short = buildIdentityPayload({ soulMd: "too short", identityMd: "also short" });
|
|
69
|
+
expect(short.soulMd).toBeUndefined();
|
|
70
|
+
expect(short.identityMd).toBeUndefined();
|
|
71
|
+
// The guard must be VISIBLE — it logs why it skipped.
|
|
72
|
+
expect(errSpy).toHaveBeenCalled();
|
|
73
|
+
} finally {
|
|
74
|
+
errSpy.mockRestore();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("TOOLS.md has no min-length guard (any non-empty content syncs)", () => {
|
|
79
|
+
const payload = buildIdentityPayload({ toolsMd: "short tools" });
|
|
80
|
+
expect(payload.toolsMd).toBe("short tools");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("HEARTBEAT.md syncs even when empty (no trim/min-length guard)", () => {
|
|
84
|
+
const payload = buildIdentityPayload({ heartbeatMd: "" });
|
|
85
|
+
expect(payload.heartbeatMd).toBe("");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("skips files that exceed the max length", () => {
|
|
89
|
+
const huge = "z".repeat(65537);
|
|
90
|
+
const payload = buildIdentityPayload({ soulMd: huge, toolsMd: huge });
|
|
91
|
+
expect(payload.soulMd).toBeUndefined();
|
|
92
|
+
expect(payload.toolsMd).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("absent files (undefined) produce no keys", () => {
|
|
96
|
+
expect(buildIdentityPayload({})).toEqual({});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("collectProfilePayloads (field gate)", () => {
|
|
101
|
+
const reader = (files: Record<string, string>): FileReader => {
|
|
102
|
+
return async (path: string) => files[path];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
test("only the selected field group is collected", async () => {
|
|
106
|
+
const files = reader({
|
|
107
|
+
[SOUL_MD_PATH]: LONG,
|
|
108
|
+
[IDENTITY_MD_PATH]: LONG,
|
|
109
|
+
[TOOLS_MD_PATH]: "tools",
|
|
110
|
+
[CLAUDE_MD_PATH]: "claude md content",
|
|
111
|
+
[SETUP_SCRIPT_PATH]: `${MARKER_START}\nexport X=1\n${MARKER_END}`,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const setupOnly = await collectProfilePayloads(["setup"], "session_sync", files);
|
|
115
|
+
expect(setupOnly.map((p) => p.label)).toEqual(["setup"]);
|
|
116
|
+
expect(setupOnly[0]?.body).toEqual({ setupScript: "export X=1", changeSource: "session_sync" });
|
|
117
|
+
|
|
118
|
+
const claudeOnly = await collectProfilePayloads(["claude"], "session_sync", files);
|
|
119
|
+
expect(claudeOnly.map((p) => p.label)).toEqual(["claude"]);
|
|
120
|
+
|
|
121
|
+
const all = await collectProfilePayloads(
|
|
122
|
+
["identity", "claude", "setup"],
|
|
123
|
+
"session_sync",
|
|
124
|
+
files,
|
|
125
|
+
);
|
|
126
|
+
expect(all.map((p) => p.label).sort()).toEqual(["claude", "identity", "setup"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a missing file yields no payload for that group (no empty POST)", async () => {
|
|
130
|
+
const files = reader({}); // nothing on disk
|
|
131
|
+
const payloads = await collectProfilePayloads(
|
|
132
|
+
["identity", "claude", "setup"],
|
|
133
|
+
"session_sync",
|
|
134
|
+
files,
|
|
135
|
+
);
|
|
136
|
+
expect(payloads).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("propagates the changeSource into every body", async () => {
|
|
140
|
+
const files = reader({ [TOOLS_MD_PATH]: "tools" });
|
|
141
|
+
const payloads = await collectProfilePayloads(["identity"], "self_edit", files);
|
|
142
|
+
expect(payloads[0]?.body.changeSource).toBe("self_edit");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("non-Claude providers sync /workspace/CLAUDE.md, not the personal file", async () => {
|
|
146
|
+
// A codex/pi/opencode session edits the runner-materialized workspace file;
|
|
147
|
+
// the Claude personal file (~/.claude/CLAUDE.md) is absent for them.
|
|
148
|
+
const files = reader({ [WORKSPACE_CLAUDE_MD_PATH]: "workspace claude md edit" });
|
|
149
|
+
|
|
150
|
+
const payloads = await collectProfilePayloads(
|
|
151
|
+
["claude"],
|
|
152
|
+
"session_sync",
|
|
153
|
+
files,
|
|
154
|
+
WORKSPACE_CLAUDE_MD_PATH,
|
|
155
|
+
);
|
|
156
|
+
expect(payloads.map((p) => p.label)).toEqual(["claude"]);
|
|
157
|
+
expect(payloads[0]?.body).toEqual({
|
|
158
|
+
claudeMd: "workspace claude md edit",
|
|
159
|
+
changeSource: "session_sync",
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("Claude's default path never reads the workspace materialization", async () => {
|
|
164
|
+
// Guard against reverting a real Claude personal-file edit: with the default
|
|
165
|
+
// (personal-file) path, content sitting only at /workspace/CLAUDE.md — the
|
|
166
|
+
// stale boot materialization — must NOT be picked up as a claude payload.
|
|
167
|
+
const files = reader({ [WORKSPACE_CLAUDE_MD_PATH]: "stale workspace materialization" });
|
|
168
|
+
|
|
169
|
+
const payloads = await collectProfilePayloads(["claude"], "session_sync", files);
|
|
170
|
+
expect(payloads).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("resolveClaudeMdPath (per-batch provider routing)", () => {
|
|
175
|
+
test("an all-Claude batch uses the personal-file path (Stop-hook backstop)", () => {
|
|
176
|
+
expect(resolveClaudeMdPath(["claude"])).toBe(CLAUDE_MD_PATH);
|
|
177
|
+
expect(resolveClaudeMdPath(["claude", "claude"])).toBe(CLAUDE_MD_PATH);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("any non-Claude local session routes to the workspace file", () => {
|
|
181
|
+
expect(resolveClaudeMdPath(["codex"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
182
|
+
expect(resolveClaudeMdPath(["pi"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
183
|
+
expect(resolveClaudeMdPath(["opencode"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
184
|
+
// Mixed batch: a non-Claude edit means the workspace file is authoritative.
|
|
185
|
+
expect(resolveClaudeMdPath(["claude", "codex"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("postProfileUpdate (non-2xx is surfaced, not swallowed)", () => {
|
|
190
|
+
const opts = {
|
|
191
|
+
agentId: "agent-1",
|
|
192
|
+
apiUrl: "https://api.example.test",
|
|
193
|
+
apiKey: "secret-key",
|
|
194
|
+
};
|
|
195
|
+
const payload = {
|
|
196
|
+
label: "setup",
|
|
197
|
+
body: { setupScript: "export X=1", changeSource: "session_sync" },
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
test("a successful 2xx response logs no warning", async () => {
|
|
201
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
202
|
+
const fetchImpl = (async () => new Response("{}", { status: 200 })) as typeof fetch;
|
|
203
|
+
try {
|
|
204
|
+
await postProfileUpdate({ ...opts, fetchImpl }, payload);
|
|
205
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
206
|
+
} finally {
|
|
207
|
+
warnSpy.mockRestore();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a non-2xx response surfaces a warning but does NOT throw", async () => {
|
|
212
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
213
|
+
const fetchImpl = (async () =>
|
|
214
|
+
new Response("boom", { status: 500, statusText: "Server Error" })) as typeof fetch;
|
|
215
|
+
try {
|
|
216
|
+
// Must resolve (non-fatal), not reject.
|
|
217
|
+
await expect(postProfileUpdate({ ...opts, fetchImpl }, payload)).resolves.toBeUndefined();
|
|
218
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
219
|
+
const msg = String(warnSpy.mock.calls[0]?.[0]);
|
|
220
|
+
expect(msg).toContain("setup sync failed");
|
|
221
|
+
expect(msg).toContain("500");
|
|
222
|
+
} finally {
|
|
223
|
+
warnSpy.mockRestore();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a thrown fetch error surfaces a warning but does NOT throw", async () => {
|
|
228
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
229
|
+
const fetchImpl = (async () => {
|
|
230
|
+
throw new Error("network down");
|
|
231
|
+
}) as typeof fetch;
|
|
232
|
+
try {
|
|
233
|
+
await expect(postProfileUpdate({ ...opts, fetchImpl }, payload)).resolves.toBeUndefined();
|
|
234
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
235
|
+
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("setup sync errored");
|
|
236
|
+
} finally {
|
|
237
|
+
warnSpy.mockRestore();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("sends a PUT to the profile route with auth + agent headers", async () => {
|
|
242
|
+
let capturedUrl = "";
|
|
243
|
+
let capturedInit: RequestInit | undefined;
|
|
244
|
+
const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
245
|
+
capturedUrl = String(url);
|
|
246
|
+
capturedInit = init;
|
|
247
|
+
return new Response("{}", { status: 200 });
|
|
248
|
+
}) as typeof fetch;
|
|
249
|
+
|
|
250
|
+
await postProfileUpdate({ ...opts, fetchImpl }, payload);
|
|
251
|
+
|
|
252
|
+
expect(capturedUrl).toBe("https://api.example.test/api/agents/agent-1/profile");
|
|
253
|
+
expect(capturedInit?.method).toBe("PUT");
|
|
254
|
+
const headers = capturedInit?.headers as Record<string, string>;
|
|
255
|
+
expect(headers.Authorization).toBe("Bearer secret-key");
|
|
256
|
+
expect(headers["X-Agent-ID"]).toBe("agent-1");
|
|
257
|
+
expect(JSON.parse(String(capturedInit?.body))).toEqual(payload.body);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("syncProfileFilesToServer (orchestration is non-fatal)", () => {
|
|
262
|
+
test("resolves without throwing even when every POST fails", async () => {
|
|
263
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
264
|
+
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
265
|
+
const fetchImpl = (async () => new Response("nope", { status: 503 })) as typeof fetch;
|
|
266
|
+
try {
|
|
267
|
+
await expect(
|
|
268
|
+
syncProfileFilesToServer({
|
|
269
|
+
agentId: "agent-1",
|
|
270
|
+
apiUrl: "https://api.example.test",
|
|
271
|
+
apiKey: "secret-key",
|
|
272
|
+
changeSource: "session_sync",
|
|
273
|
+
// No files on a CI box → typically no payloads; still must never throw.
|
|
274
|
+
fetchImpl,
|
|
275
|
+
}),
|
|
276
|
+
).resolves.toBeUndefined();
|
|
277
|
+
} finally {
|
|
278
|
+
warnSpy.mockRestore();
|
|
279
|
+
errSpy.mockRestore();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -54,7 +54,7 @@ describe("Session templates — registration", () => {
|
|
|
54
54
|
await ensureTemplatesRegistered();
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
test("all
|
|
57
|
+
test("all 15 system templates are registered", () => {
|
|
58
58
|
const systemTemplates = [
|
|
59
59
|
"system.agent.role",
|
|
60
60
|
"system.agent.register",
|
|
@@ -66,6 +66,7 @@ describe("Session templates — registration", () => {
|
|
|
66
66
|
"system.agent.agent_fs",
|
|
67
67
|
"system.agent.self_awareness",
|
|
68
68
|
"system.agent.context_mode",
|
|
69
|
+
"system.agent.seed_scripts",
|
|
69
70
|
|
|
70
71
|
"system.agent.system",
|
|
71
72
|
"system.agent.services",
|
|
@@ -90,12 +91,12 @@ describe("Session templates — registration", () => {
|
|
|
90
91
|
}
|
|
91
92
|
});
|
|
92
93
|
|
|
93
|
-
test("total of
|
|
94
|
+
test("total of 21 session/system templates registered", () => {
|
|
94
95
|
const all = getAllTemplateDefinitions();
|
|
95
96
|
const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
|
|
96
|
-
//
|
|
97
|
-
// composite
|
|
98
|
-
expect(sessionSystem.length).toBe(
|
|
97
|
+
// 21 = the original 19 + `system.session.worker.pi` + `system.agent.seed_scripts`.
|
|
98
|
+
// The pi composite omits script nudges because pi workers do not have MCP.
|
|
99
|
+
expect(sessionSystem.length).toBe(21);
|
|
99
100
|
});
|
|
100
101
|
});
|
|
101
102
|
|
|
@@ -187,6 +188,15 @@ describe("Session templates — individual resolution", () => {
|
|
|
187
188
|
expect(result.text).toContain("batch_execute");
|
|
188
189
|
});
|
|
189
190
|
|
|
191
|
+
test("system.agent.seed_scripts points agents at task-start scripts", () => {
|
|
192
|
+
const result = resolveTemplate("system.agent.seed_scripts", {});
|
|
193
|
+
expect(result.text).toContain("Pre-built Seed Scripts");
|
|
194
|
+
expect(result.text).toContain("task-context-gathering");
|
|
195
|
+
expect(result.text).toContain("smart-recall");
|
|
196
|
+
expect(result.text).toContain("script-search");
|
|
197
|
+
expect(result.text).not.toContain("compound-insights");
|
|
198
|
+
});
|
|
199
|
+
|
|
190
200
|
// system.agent.guidelines was removed — its content was redundant with worker/lead templates
|
|
191
201
|
|
|
192
202
|
test("system.agent.system contains package info", () => {
|
|
@@ -240,6 +250,7 @@ describe("Session templates — composite resolution", () => {
|
|
|
240
250
|
|
|
241
251
|
// Contains context_mode
|
|
242
252
|
expect(result.text).toContain("Context Window Management");
|
|
253
|
+
expect(result.text).toContain("Pre-built Seed Scripts");
|
|
243
254
|
|
|
244
255
|
// Guidelines template was removed (redundant with lead/worker templates)
|
|
245
256
|
|
|
@@ -275,6 +286,7 @@ describe("Session templates — composite resolution", () => {
|
|
|
275
286
|
|
|
276
287
|
// Contains context_mode
|
|
277
288
|
expect(result.text).toContain("Context Window Management");
|
|
289
|
+
expect(result.text).toContain("Pre-built Seed Scripts");
|
|
278
290
|
|
|
279
291
|
// Guidelines template was removed (redundant with lead/worker templates)
|
|
280
292
|
|
|
@@ -309,6 +321,8 @@ describe("Session templates — composite resolution", () => {
|
|
|
309
321
|
expect(workerResult.text).toContain("join-swarm");
|
|
310
322
|
expect(leadResult.text).toContain("How You Are Built");
|
|
311
323
|
expect(workerResult.text).toContain("How You Are Built");
|
|
324
|
+
expect(leadResult.text).toContain("Pre-built Seed Scripts");
|
|
325
|
+
expect(workerResult.text).toContain("Pre-built Seed Scripts");
|
|
312
326
|
|
|
313
327
|
// Lead has lead content, not worker
|
|
314
328
|
expect(leadResult.text).toContain("CRITICAL: You are a coordinator");
|
|
@@ -343,6 +357,7 @@ describe("Session templates — getBasePrompt integration", () => {
|
|
|
343
357
|
expect(result).toContain("join-swarm");
|
|
344
358
|
expect(result).toContain("store-progress");
|
|
345
359
|
expect(result).toContain("How You Are Built");
|
|
360
|
+
expect(result).toContain("Pre-built Seed Scripts");
|
|
346
361
|
expect(result).toContain("System packages available");
|
|
347
362
|
|
|
348
363
|
// Conditional sections (services included by default)
|
|
@@ -365,8 +380,22 @@ describe("Session templates — getBasePrompt integration", () => {
|
|
|
365
380
|
expect(result).toContain("your role is: lead");
|
|
366
381
|
expect(result).toContain("integration-test-lead");
|
|
367
382
|
expect(result).toContain("CRITICAL: You are a coordinator");
|
|
383
|
+
expect(result).toContain("Pre-built Seed Scripts");
|
|
368
384
|
|
|
369
385
|
// Should NOT have worker content
|
|
370
386
|
expect(result).not.toContain("task-action");
|
|
371
387
|
});
|
|
388
|
+
|
|
389
|
+
test("getBasePrompt excludes seed_scripts for pi worker", async () => {
|
|
390
|
+
const { getBasePrompt } = await import("../prompts/base-prompt");
|
|
391
|
+
const result = await getBasePrompt({
|
|
392
|
+
role: "worker",
|
|
393
|
+
agentId: "integration-test-pi",
|
|
394
|
+
swarmUrl: "swarm.test.com",
|
|
395
|
+
provider: "pi",
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
expect(result).not.toContain("Pre-built Seed Scripts");
|
|
399
|
+
expect(result).not.toContain("task-context-gathering");
|
|
400
|
+
});
|
|
372
401
|
});
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { closeDb, createAgent, getDb, initDb, updateScriptRun } from "../be/db";
|
|
6
|
+
import { handleCore } from "../http/core";
|
|
7
|
+
import { handleScriptRuns } from "../http/script-runs";
|
|
8
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
9
|
+
import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
|
|
10
|
+
|
|
11
|
+
const TEST_DB_PATH = "./test-script-runs-http.sqlite";
|
|
12
|
+
const API_KEY = "test-script-runs-http-key-1234567890";
|
|
13
|
+
|
|
14
|
+
let agentId: string;
|
|
15
|
+
let savedEnv: NodeJS.ProcessEnv;
|
|
16
|
+
|
|
17
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
18
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
19
|
+
try {
|
|
20
|
+
await unlink(path + suffix);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
savedEnv = { ...process.env };
|
|
29
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
30
|
+
initDb(TEST_DB_PATH);
|
|
31
|
+
process.env.AGENT_SWARM_API_KEY = API_KEY;
|
|
32
|
+
process.env.APP_URL = "https://app.example.test";
|
|
33
|
+
process.env.SCRIPT_RUN_SUPERVISOR_DISABLE = "true";
|
|
34
|
+
delete process.env.API_KEY;
|
|
35
|
+
refreshSecretScrubberCache();
|
|
36
|
+
|
|
37
|
+
const agent = createAgent({ name: "script-runs-worker", isLead: false, status: "idle" });
|
|
38
|
+
agentId = agent.id;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
closeDb();
|
|
43
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
44
|
+
for (const key of Object.keys(process.env)) {
|
|
45
|
+
if (!(key in savedEnv)) delete process.env[key];
|
|
46
|
+
}
|
|
47
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
48
|
+
if (value === undefined) delete process.env[key];
|
|
49
|
+
else process.env[key] = value;
|
|
50
|
+
}
|
|
51
|
+
refreshSecretScrubberCache();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
getDb().run("DELETE FROM script_run_journal");
|
|
56
|
+
getDb().run("DELETE FROM script_runs");
|
|
57
|
+
delete process.env.SCRIPT_RUN_CONCURRENCY_CAP;
|
|
58
|
+
delete process.env.SCRIPT_RUN_MAX_STEPS;
|
|
59
|
+
delete process.env.SCRIPT_RUN_MAX_AGENT_TASKS;
|
|
60
|
+
delete process.env.SCRIPT_RUN_MAX_WALL_MS;
|
|
61
|
+
process.env.SCRIPT_RUN_SUPERVISOR_DISABLE = "true";
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
type TestResponse = {
|
|
65
|
+
status: number;
|
|
66
|
+
text: string;
|
|
67
|
+
json: () => Promise<unknown>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
async function dispatch(
|
|
71
|
+
path: string,
|
|
72
|
+
init: RequestInit & { agentId?: string } = {},
|
|
73
|
+
): Promise<TestResponse> {
|
|
74
|
+
const headers: Record<string, string> = {
|
|
75
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
...((init.headers as Record<string, string>) ?? {}),
|
|
78
|
+
};
|
|
79
|
+
if (init.agentId !== undefined) headers["X-Agent-ID"] = init.agentId;
|
|
80
|
+
const req = Readable.from(init.body ? [Buffer.from(String(init.body))] : []) as IncomingMessage;
|
|
81
|
+
req.method = init.method ?? "GET";
|
|
82
|
+
req.url = path;
|
|
83
|
+
req.headers = Object.fromEntries(
|
|
84
|
+
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
let status = 200;
|
|
88
|
+
let text = "";
|
|
89
|
+
const res = {
|
|
90
|
+
headersSent: false,
|
|
91
|
+
writableEnded: false,
|
|
92
|
+
setHeader() {},
|
|
93
|
+
writeHead(code: number) {
|
|
94
|
+
status = code;
|
|
95
|
+
this.headersSent = true;
|
|
96
|
+
return this;
|
|
97
|
+
},
|
|
98
|
+
end(chunk?: unknown) {
|
|
99
|
+
if (chunk !== undefined) text += String(chunk);
|
|
100
|
+
this.writableEnded = true;
|
|
101
|
+
return this;
|
|
102
|
+
},
|
|
103
|
+
} as unknown as ServerResponse;
|
|
104
|
+
|
|
105
|
+
const requestAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
106
|
+
if (!(await handleCore(req, res, requestAgentId, API_KEY))) {
|
|
107
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
108
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
109
|
+
if (!(await handleScriptRuns(req, res, pathSegments, queryParams, requestAgentId))) {
|
|
110
|
+
res.writeHead(404);
|
|
111
|
+
res.end("Not Found");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
status,
|
|
117
|
+
text,
|
|
118
|
+
json: async () => JSON.parse(text),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createBody(extra: Record<string, unknown> = {}): string {
|
|
123
|
+
return JSON.stringify({
|
|
124
|
+
source: "export default async function main() { return { ok: true }; }",
|
|
125
|
+
args: { ok: true },
|
|
126
|
+
...extra,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("/api/script-runs HTTP", () => {
|
|
131
|
+
test("creates and lists a script run", async () => {
|
|
132
|
+
const created = await dispatch("/api/script-runs", {
|
|
133
|
+
method: "POST",
|
|
134
|
+
agentId,
|
|
135
|
+
body: createBody({ scriptName: "daily-report" }),
|
|
136
|
+
});
|
|
137
|
+
expect(created.status).toBe(201);
|
|
138
|
+
const body = (await created.json()) as { id: string; status: string; url: string };
|
|
139
|
+
expect(body.status).toBe("running");
|
|
140
|
+
expect(body.url).toBe(`https://app.example.test/script-runs/${body.id}`);
|
|
141
|
+
|
|
142
|
+
const listed = await dispatch("/api/script-runs", { agentId });
|
|
143
|
+
expect(listed.status).toBe(200);
|
|
144
|
+
const listBody = (await listed.json()) as { runs: Array<{ id: string }>; total: number };
|
|
145
|
+
expect(listBody.total).toBe(1);
|
|
146
|
+
expect(listBody.runs[0]?.id).toBe(body.id);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("returns the existing run for an idempotency key", async () => {
|
|
150
|
+
const first = await dispatch("/api/script-runs", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
agentId,
|
|
153
|
+
body: createBody({ idempotencyKey: "stable-key" }),
|
|
154
|
+
});
|
|
155
|
+
const firstBody = (await first.json()) as { id: string };
|
|
156
|
+
|
|
157
|
+
const second = await dispatch("/api/script-runs", {
|
|
158
|
+
method: "POST",
|
|
159
|
+
agentId,
|
|
160
|
+
body: createBody({ idempotencyKey: "stable-key" }),
|
|
161
|
+
});
|
|
162
|
+
expect(second.status).toBe(409);
|
|
163
|
+
const secondBody = (await second.json()) as { id: string };
|
|
164
|
+
expect(secondBody.id).toBe(firstBody.id);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("rejects obvious literal labels inside loops before launch", async () => {
|
|
168
|
+
const rejected = await dispatch("/api/script-runs", {
|
|
169
|
+
method: "POST",
|
|
170
|
+
agentId,
|
|
171
|
+
body: createBody({
|
|
172
|
+
source:
|
|
173
|
+
'export default async function main(args, ctx) { for (const item of args.items) { await ctx.step.agentTask("process", { task: item.task }); } }',
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
expect(rejected.status).toBe(400);
|
|
177
|
+
const body = (await rejected.json()) as { error: string; violations: Array<{ label: string }> };
|
|
178
|
+
expect(body.error).toBe("label_lint_violation");
|
|
179
|
+
expect(body.violations[0]?.label).toBe("process");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("records and replays journal steps through internal routes", async () => {
|
|
183
|
+
const created = await dispatch("/api/script-runs", {
|
|
184
|
+
method: "POST",
|
|
185
|
+
agentId,
|
|
186
|
+
body: createBody(),
|
|
187
|
+
});
|
|
188
|
+
const { id } = (await created.json()) as { id: string };
|
|
189
|
+
|
|
190
|
+
const recorded = await dispatch(`/api/internal/script-runs/${id}/steps`, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
agentId,
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
stepKey: "summarize",
|
|
195
|
+
stepType: "raw-llm",
|
|
196
|
+
config: { prompt: "hello" },
|
|
197
|
+
status: "completed",
|
|
198
|
+
result: { text: "hi" },
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
expect(recorded.status).toBe(201);
|
|
202
|
+
|
|
203
|
+
const replayed = await dispatch(`/api/internal/script-runs/${id}/steps/summarize`, {
|
|
204
|
+
agentId,
|
|
205
|
+
});
|
|
206
|
+
expect(replayed.status).toBe(200);
|
|
207
|
+
expect(await replayed.json()).toEqual({
|
|
208
|
+
stepKey: "summarize",
|
|
209
|
+
stepType: "raw-llm",
|
|
210
|
+
result: { text: "hi" },
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("aborts the run when the journal step cap is exceeded", async () => {
|
|
215
|
+
process.env.SCRIPT_RUN_MAX_STEPS = "1";
|
|
216
|
+
const created = await dispatch("/api/script-runs", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
agentId,
|
|
219
|
+
body: createBody(),
|
|
220
|
+
});
|
|
221
|
+
const { id } = (await created.json()) as { id: string };
|
|
222
|
+
|
|
223
|
+
const first = await dispatch(`/api/internal/script-runs/${id}/steps`, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
agentId,
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
stepKey: "one",
|
|
228
|
+
stepType: "swarm-script",
|
|
229
|
+
status: "completed",
|
|
230
|
+
result: 1,
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
expect(first.status).toBe(201);
|
|
234
|
+
|
|
235
|
+
const second = await dispatch(`/api/internal/script-runs/${id}/steps`, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
agentId,
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
stepKey: "two",
|
|
240
|
+
stepType: "swarm-script",
|
|
241
|
+
status: "completed",
|
|
242
|
+
result: 2,
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
expect(second.status).toBe(429);
|
|
246
|
+
|
|
247
|
+
const detail = await dispatch(`/api/script-runs/${id}`, { agentId });
|
|
248
|
+
const body = (await detail.json()) as { run: { status: string; error?: string } };
|
|
249
|
+
expect(body.run.status).toBe("aborted_limit");
|
|
250
|
+
expect(body.run.error).toContain("SCRIPT_RUN_MAX_STEPS");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("cancel leaves terminal script runs unchanged", async () => {
|
|
254
|
+
const created = await dispatch("/api/script-runs", {
|
|
255
|
+
method: "POST",
|
|
256
|
+
agentId,
|
|
257
|
+
body: createBody(),
|
|
258
|
+
});
|
|
259
|
+
const { id } = (await created.json()) as { id: string };
|
|
260
|
+
updateScriptRun(id, {
|
|
261
|
+
status: "failed",
|
|
262
|
+
pid: null,
|
|
263
|
+
finishedAt: new Date().toISOString(),
|
|
264
|
+
error: "original failure",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const cancelled = await dispatch(`/api/script-runs/${id}`, {
|
|
268
|
+
method: "DELETE",
|
|
269
|
+
agentId,
|
|
270
|
+
});
|
|
271
|
+
expect(cancelled.status).toBe(204);
|
|
272
|
+
|
|
273
|
+
const detail = await dispatch(`/api/script-runs/${id}`, { agentId });
|
|
274
|
+
const body = (await detail.json()) as { run: { status: string; error?: string } };
|
|
275
|
+
expect(body.run.status).toBe("failed");
|
|
276
|
+
expect(body.run.error).toBe("original failure");
|
|
277
|
+
});
|
|
278
|
+
});
|