@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.
- package/README.md +7 -0
- package/openapi.json +41 -1
- package/package.json +3 -2
- package/plugin/skills/composio/SKILL.md +173 -0
- package/plugin/skills/composio-gmail/SKILL.md +83 -0
- package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
- package/plugin/skills/composio-google-docs/SKILL.md +71 -0
- package/src/be/db.ts +353 -2
- package/src/be/migrations/081_metrics.sql +39 -0
- package/src/be/migrations/082_user_audit_fields.sql +120 -0
- package/src/be/modelsdev-cache.json +3413 -1423
- package/src/be/seed-skills/index.ts +7 -0
- package/src/cli.tsx +18 -0
- package/src/commands/runner.ts +153 -22
- package/src/commands/x.ts +118 -0
- package/src/github/handlers.ts +40 -1
- package/src/heartbeat/heartbeat.ts +80 -12
- package/src/http/active-sessions.ts +32 -1
- package/src/http/auth.ts +36 -0
- package/src/http/core.ts +20 -16
- package/src/http/db-query.ts +20 -0
- package/src/http/index.ts +2 -0
- package/src/http/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/tasks.ts +6 -1
- package/src/http/workflows.ts +5 -1
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +8 -0
- package/src/prompts/session-templates.ts +23 -0
- package/src/providers/opencode-adapter.ts +22 -6
- package/src/server.ts +10 -1
- package/src/tasks/worker-follow-up.ts +19 -1
- package/src/tests/base-prompt.test.ts +35 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/core-auth.test.ts +8 -1
- package/src/tests/events-http.test.ts +6 -2
- package/src/tests/github-handlers-cancel-config.test.ts +262 -0
- package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
- package/src/tests/heartbeat.test.ts +84 -3
- package/src/tests/http-api-integration.test.ts +3 -1
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +90 -30
- package/src/tests/runner-repo-autostash.test.ts +117 -0
- package/src/tests/runner-requester-profile.test.ts +25 -0
- package/src/tests/runner-skills-refresh.test.ts +1 -1
- package/src/tests/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +3 -0
- package/src/tests/ui-logs-parser.test.ts +271 -0
- package/src/tests/user-token-rest-auth.test.ts +129 -0
- package/src/tests/workflow-async-v2.test.ts +23 -0
- package/src/tests/x-composio.test.ts +122 -0
- package/src/tools/create-metric.ts +191 -0
- package/src/tools/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +120 -0
- package/src/utils/request-auth-context.ts +28 -0
- package/src/utils/skills-refresh.ts +2 -2
- package/src/workflows/engine.ts +24 -2
- package/src/workflows/executors/agent-task.ts +2 -0
- package/src/x/composio.ts +295 -0
- package/templates/skills/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- package/templates/skills/attio-interaction/content.md +272 -0
|
@@ -36,7 +36,9 @@ describe("system-default skills", () => {
|
|
|
36
36
|
const skills = loadSeedSkills();
|
|
37
37
|
const names = skills.map((skill) => skill.name);
|
|
38
38
|
|
|
39
|
+
expect(names).toContain("attio-interaction");
|
|
39
40
|
expect(names).toContain("swarm-scripts");
|
|
41
|
+
expect(skills.find((skill) => skill.name === "attio-interaction")?.systemDefault).toBe(true);
|
|
40
42
|
expect(skills.find((skill) => skill.name === "swarm-scripts")?.systemDefault).toBe(true);
|
|
41
43
|
expect(skills.find((skill) => skill.name === "kv-storage")?.systemDefault).toBe(true);
|
|
42
44
|
expect(skills.find((skill) => skill.name === "pages")?.systemDefault).toBe(true);
|
|
@@ -45,6 +47,7 @@ describe("system-default skills", () => {
|
|
|
45
47
|
expect(result.failed).toEqual([]);
|
|
46
48
|
|
|
47
49
|
const defaults = getSystemDefaultSkills().map((skill) => skill.name);
|
|
50
|
+
expect(defaults).toContain("attio-interaction");
|
|
48
51
|
expect(defaults).toContain("swarm-scripts");
|
|
49
52
|
expect(defaults).toContain("kv-storage");
|
|
50
53
|
expect(defaults).toContain("pages");
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
normalizeSessionLogs,
|
|
4
|
+
parseSessionLogs,
|
|
5
|
+
type SessionLogRecord,
|
|
6
|
+
unwrapResult,
|
|
7
|
+
} from "../../ui/src/logs-parser";
|
|
8
|
+
|
|
9
|
+
function log(
|
|
10
|
+
id: string,
|
|
11
|
+
cli: string,
|
|
12
|
+
lineNumber: number,
|
|
13
|
+
content: unknown,
|
|
14
|
+
createdAt = "2026-06-01T10:00:00.000Z",
|
|
15
|
+
): SessionLogRecord {
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
taskId: "task-1",
|
|
19
|
+
sessionId: "session-1",
|
|
20
|
+
iteration: 1,
|
|
21
|
+
cli,
|
|
22
|
+
content: typeof content === "string" ? content : JSON.stringify(content),
|
|
23
|
+
lineNumber,
|
|
24
|
+
createdAt,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("ui logs parser", () => {
|
|
29
|
+
test("orders opencode deltas before reassembling streamed text", () => {
|
|
30
|
+
const result = normalizeSessionLogs([
|
|
31
|
+
log("delta-2", "opencode", 2, {
|
|
32
|
+
type: "message.part.delta",
|
|
33
|
+
properties: { partID: "part-1", delta: "world" },
|
|
34
|
+
}),
|
|
35
|
+
log("updated", "opencode", 3, {
|
|
36
|
+
type: "message.part.updated",
|
|
37
|
+
properties: { part: { id: "part-1", type: "text" } },
|
|
38
|
+
}),
|
|
39
|
+
log("delta-1", "opencode", 1, {
|
|
40
|
+
type: "message.part.delta",
|
|
41
|
+
properties: { partID: "part-1", delta: "Hello " },
|
|
42
|
+
}),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
expect(result.gate).toEqual({ total: 3, ok: 3, bad: 0, passed: true });
|
|
46
|
+
expect(result.items).toHaveLength(1);
|
|
47
|
+
expect(result.items[0]?.kind).toBe("text");
|
|
48
|
+
expect(result.items[0]?.text).toBe("Hello world");
|
|
49
|
+
expect(result.items[0]?.recId).toBe("delta-1");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("pairs codex started and completed tool events by item id", () => {
|
|
53
|
+
const result = normalizeSessionLogs([
|
|
54
|
+
log("start", "codex", 1, {
|
|
55
|
+
type: "item.started",
|
|
56
|
+
item: { id: "item-1", type: "command_execution", command: "pwd" },
|
|
57
|
+
}),
|
|
58
|
+
log("done", "codex", 2, {
|
|
59
|
+
type: "item.completed",
|
|
60
|
+
item: {
|
|
61
|
+
id: "item-1",
|
|
62
|
+
type: "command_execution",
|
|
63
|
+
aggregated_output: "/tmp\n",
|
|
64
|
+
exit_code: 0,
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
expect(result.items.map((item) => item.kind)).toEqual(["tool_call", "tool_result"]);
|
|
70
|
+
expect(result.pairing.paired).toBe(1);
|
|
71
|
+
expect(result.pairing.orphanCalls).toEqual([]);
|
|
72
|
+
expect(result.pairing.orphanResults).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("normalizes claude-managed raw SSE events without unknown noise", () => {
|
|
76
|
+
const result = normalizeSessionLogs([
|
|
77
|
+
log("status", "claude-managed", 1, {
|
|
78
|
+
type: "session.status_running",
|
|
79
|
+
id: "evt-running",
|
|
80
|
+
}),
|
|
81
|
+
log("message", "claude-managed", 2, {
|
|
82
|
+
type: "agent.message",
|
|
83
|
+
id: "evt-message",
|
|
84
|
+
content: [{ type: "text", text: "Hello from managed agent" }],
|
|
85
|
+
}),
|
|
86
|
+
log("tool", "claude-managed", 3, {
|
|
87
|
+
type: "agent.tool_use",
|
|
88
|
+
id: "tool-1",
|
|
89
|
+
name: "read_file",
|
|
90
|
+
input: { path: "/etc/hosts" },
|
|
91
|
+
}),
|
|
92
|
+
log("result", "claude-managed", 4, {
|
|
93
|
+
type: "agent.tool_result",
|
|
94
|
+
id: "tool-result-1",
|
|
95
|
+
tool_use_id: "tool-1",
|
|
96
|
+
content: [{ type: "text", text: "127.0.0.1 localhost" }],
|
|
97
|
+
is_error: false,
|
|
98
|
+
}),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(result.items.map((item) => item.kind)).toEqual([
|
|
102
|
+
"lifecycle",
|
|
103
|
+
"text",
|
|
104
|
+
"tool_call",
|
|
105
|
+
"tool_result",
|
|
106
|
+
]);
|
|
107
|
+
expect(result.items.some((item) => item.kind === "unknown")).toBe(false);
|
|
108
|
+
expect(result.pairing.paired).toBe(1);
|
|
109
|
+
expect(result.pairing.orphanCalls).toEqual([]);
|
|
110
|
+
expect(result.pairing.orphanResults).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("keeps parse errors visible in the compatibility message output", () => {
|
|
114
|
+
const messages = parseSessionLogs([log("bad", "claude", 1, "{not-json")]);
|
|
115
|
+
expect(messages).toHaveLength(1);
|
|
116
|
+
expect(messages[0]?.role).toBe("system");
|
|
117
|
+
expect(messages[0]?.content[0]).toEqual({
|
|
118
|
+
type: "provider_meta",
|
|
119
|
+
kind: "parse_error",
|
|
120
|
+
provider: "claude",
|
|
121
|
+
data: { raw: "{not-json" },
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("classifies claude runtime noise as internal or helper metadata", () => {
|
|
126
|
+
const messages = parseSessionLogs([
|
|
127
|
+
log("rate", "claude", 1, {
|
|
128
|
+
type: "rate_limit_event",
|
|
129
|
+
rate_limit_info: { status: "rejected", resetsAt: 1779202200 },
|
|
130
|
+
}),
|
|
131
|
+
log("think", "claude", 2, {
|
|
132
|
+
type: "system",
|
|
133
|
+
subtype: "thinking_tokens",
|
|
134
|
+
estimated_tokens: 150,
|
|
135
|
+
estimated_tokens_delta: 100,
|
|
136
|
+
}),
|
|
137
|
+
log("hook", "claude", 3, {
|
|
138
|
+
type: "system",
|
|
139
|
+
subtype: "hook_response",
|
|
140
|
+
hook_id: "hook-1",
|
|
141
|
+
hook_event: "SessionStart",
|
|
142
|
+
outcome: "success",
|
|
143
|
+
}),
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
expect(messages.map((message) => message.content[0])).toEqual([
|
|
147
|
+
expect.objectContaining({
|
|
148
|
+
type: "provider_meta",
|
|
149
|
+
kind: "internal",
|
|
150
|
+
data: expect.objectContaining({ internalType: "rate_limit" }),
|
|
151
|
+
}),
|
|
152
|
+
expect.objectContaining({
|
|
153
|
+
type: "provider_meta",
|
|
154
|
+
kind: "helper",
|
|
155
|
+
data: expect.objectContaining({ helperType: "thinking_tokens" }),
|
|
156
|
+
}),
|
|
157
|
+
expect.objectContaining({
|
|
158
|
+
type: "provider_meta",
|
|
159
|
+
kind: "internal",
|
|
160
|
+
data: expect.objectContaining({ internalType: "hook" }),
|
|
161
|
+
}),
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("classifies codex and opencode lifecycle rows for shared rendering", () => {
|
|
166
|
+
const codex = parseSessionLogs([
|
|
167
|
+
log("turn", "codex", 1, {
|
|
168
|
+
type: "turn.completed",
|
|
169
|
+
usage: { input_tokens: 100, cached_input_tokens: 50, output_tokens: 10 },
|
|
170
|
+
}),
|
|
171
|
+
]);
|
|
172
|
+
const opencode = parseSessionLogs([
|
|
173
|
+
log("context", "opencode", 1, {
|
|
174
|
+
type: "context_usage",
|
|
175
|
+
contextUsedTokens: 25_000,
|
|
176
|
+
contextTotalTokens: 200_000,
|
|
177
|
+
contextPercent: 12.5,
|
|
178
|
+
}),
|
|
179
|
+
log("session", "opencode", 2, {
|
|
180
|
+
type: "session_init",
|
|
181
|
+
sessionId: "ses_1",
|
|
182
|
+
provider: "opencode",
|
|
183
|
+
}),
|
|
184
|
+
log("heartbeat", "opencode", 3, { type: "server.heartbeat", properties: {} }),
|
|
185
|
+
log("connected", "opencode", 4, { type: "server.connected", properties: {} }),
|
|
186
|
+
log("result", "opencode", 5, {
|
|
187
|
+
type: "result",
|
|
188
|
+
cost: { totalCostUsd: 0.12, inputTokens: 100, outputTokens: 20 },
|
|
189
|
+
isError: false,
|
|
190
|
+
}),
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
expect(codex[0]?.content[0]).toEqual(
|
|
194
|
+
expect.objectContaining({
|
|
195
|
+
type: "provider_meta",
|
|
196
|
+
kind: "helper",
|
|
197
|
+
data: expect.objectContaining({ helperType: "turn_usage" }),
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
expect(opencode.map((message) => message.content[0])).toEqual([
|
|
201
|
+
expect.objectContaining({
|
|
202
|
+
type: "provider_meta",
|
|
203
|
+
kind: "helper",
|
|
204
|
+
data: expect.objectContaining({ helperType: "context_usage" }),
|
|
205
|
+
}),
|
|
206
|
+
expect.objectContaining({
|
|
207
|
+
type: "provider_meta",
|
|
208
|
+
kind: "internal",
|
|
209
|
+
data: expect.objectContaining({ internalType: "runtime" }),
|
|
210
|
+
}),
|
|
211
|
+
expect.objectContaining({ type: "provider_meta", kind: "result" }),
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("keeps devin provider meta and transcript messages on the generic path", () => {
|
|
216
|
+
const messages = parseSessionLogs([
|
|
217
|
+
log("status", "devin", 1, {
|
|
218
|
+
type: "system",
|
|
219
|
+
message: { role: "system", content: "" },
|
|
220
|
+
provider_meta: { provider: "devin", kind: "status", status: "running" },
|
|
221
|
+
}),
|
|
222
|
+
log("message", "devin", 2, {
|
|
223
|
+
type: "assistant",
|
|
224
|
+
message: { role: "assistant", content: "Devin update" },
|
|
225
|
+
}),
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
expect(messages.map((message) => message.content[0])).toEqual([
|
|
229
|
+
expect.objectContaining({
|
|
230
|
+
type: "provider_meta",
|
|
231
|
+
kind: "status",
|
|
232
|
+
provider: "devin",
|
|
233
|
+
data: expect.objectContaining({ status: "running" }),
|
|
234
|
+
}),
|
|
235
|
+
{ type: "text", text: "Devin update" },
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("unwraps prose followed by embedded JSON", () => {
|
|
240
|
+
expect(unwrapResult('Created file\n\n{"ok":true,"path":"a.ts"}')).toEqual({
|
|
241
|
+
prose: "Created file",
|
|
242
|
+
json: { ok: true, path: "a.ts" },
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("unwraps pi tool result content text wrappers", () => {
|
|
247
|
+
const messages = parseSessionLogs([
|
|
248
|
+
log("pi-result", "pi", 1, {
|
|
249
|
+
type: "assistant",
|
|
250
|
+
message: {
|
|
251
|
+
content: [
|
|
252
|
+
{
|
|
253
|
+
type: "tool_result",
|
|
254
|
+
tool_use_id: "functions.memory-get:1",
|
|
255
|
+
content: JSON.stringify({
|
|
256
|
+
content: [{ type: "text", text: 'Memory retrieved.\n\n{"ok":true}' }],
|
|
257
|
+
}),
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
expect(messages[0]?.content[0]).toEqual(
|
|
265
|
+
expect.objectContaining({
|
|
266
|
+
type: "tool_result",
|
|
267
|
+
content: 'Memory retrieved.\n\n{\n "ok": true\n}',
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
createServer as createHttpServer,
|
|
5
|
+
type IncomingMessage,
|
|
6
|
+
type Server,
|
|
7
|
+
type ServerResponse,
|
|
8
|
+
} from "node:http";
|
|
9
|
+
import { closeDb, createAgent, createUser, getDb, initDb } from "../be/db";
|
|
10
|
+
import { type IdentityActor, mintToken, revokeToken } from "../be/users";
|
|
11
|
+
import { handleCore } from "../http/core";
|
|
12
|
+
import { handleTasks } from "../http/tasks";
|
|
13
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
14
|
+
|
|
15
|
+
const TEST_DB_PATH = "./test-user-token-rest-auth.sqlite";
|
|
16
|
+
const API_KEY = "test-api-key";
|
|
17
|
+
const ACTOR: IdentityActor = { kind: "operator", id: "op:test" };
|
|
18
|
+
|
|
19
|
+
function createTestServer(): Server {
|
|
20
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
21
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
22
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
23
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
24
|
+
|
|
25
|
+
if (await handleCore(req, res, myAgentId, API_KEY)) return;
|
|
26
|
+
if (await handleTasks(req, res, pathSegments, queryParams, myAgentId)) return;
|
|
27
|
+
|
|
28
|
+
res.writeHead(404);
|
|
29
|
+
res.end("Not Found");
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function listen(server: Server): Promise<number> {
|
|
34
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
35
|
+
const addr = server.address();
|
|
36
|
+
if (!addr || typeof addr === "string") throw new Error("no port");
|
|
37
|
+
return addr.port;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cleanupDb() {
|
|
41
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
42
|
+
try {
|
|
43
|
+
unlinkSync(`${TEST_DB_PATH}${suffix}`);
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("normal REST API user-bound token auth", () => {
|
|
49
|
+
let server: Server;
|
|
50
|
+
let port: number;
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
cleanupDb();
|
|
54
|
+
initDb(TEST_DB_PATH);
|
|
55
|
+
createAgent({ name: "Lead", isLead: true, status: "idle" });
|
|
56
|
+
server = createTestServer();
|
|
57
|
+
port = await listen(server);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(() => {
|
|
61
|
+
server.close();
|
|
62
|
+
closeDb();
|
|
63
|
+
cleanupDb();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("POST /api/tasks accepts active user token and forces requester/audit user", async () => {
|
|
67
|
+
const user = createUser({ name: "Token REST User" });
|
|
68
|
+
const other = createUser({ name: "Other User" });
|
|
69
|
+
const { plaintext } = mintToken(user.id, "rest", ACTOR);
|
|
70
|
+
|
|
71
|
+
const res = await fetch(`http://localhost:${port}/api/tasks`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: {
|
|
74
|
+
Authorization: `Bearer ${plaintext}`,
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
task: "created through user token",
|
|
79
|
+
requestedByUserId: other.id,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(res.status).toBe(201);
|
|
84
|
+
const body = (await res.json()) as { id: string; requestedByUserId?: string };
|
|
85
|
+
expect(body.requestedByUserId).toBe(user.id);
|
|
86
|
+
|
|
87
|
+
const row = getDb()
|
|
88
|
+
.prepare<
|
|
89
|
+
{ requestedByUserId: string | null; created_by: string | null; updated_by: string | null },
|
|
90
|
+
string
|
|
91
|
+
>("SELECT requestedByUserId, created_by, updated_by FROM agent_tasks WHERE id = ?")
|
|
92
|
+
.get(body.id);
|
|
93
|
+
expect(row?.requestedByUserId).toBe(user.id);
|
|
94
|
+
expect(row?.created_by).toBe(user.id);
|
|
95
|
+
expect(row?.updated_by).toBe(user.id);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("global API key still creates unattributed tasks by default", async () => {
|
|
99
|
+
const res = await fetch(`http://localhost:${port}/api/tasks`, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: {
|
|
102
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify({ task: "created through global key" }),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(res.status).toBe(201);
|
|
109
|
+
const body = (await res.json()) as { id: string; requestedByUserId?: string };
|
|
110
|
+
expect(body.requestedByUserId).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("revoked user token is unauthorized for normal API", async () => {
|
|
114
|
+
const user = createUser({ name: "Revoked REST User" });
|
|
115
|
+
const { tokenId, plaintext } = mintToken(user.id, "revoked", ACTOR);
|
|
116
|
+
revokeToken(tokenId, ACTOR);
|
|
117
|
+
|
|
118
|
+
const res = await fetch(`http://localhost:${port}/api/tasks`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: {
|
|
121
|
+
Authorization: `Bearer ${plaintext}`,
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify({ task: "should not be created" }),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(res.status).toBe(401);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import {
|
|
5
5
|
closeDb,
|
|
6
6
|
completeTask,
|
|
7
|
+
createUser,
|
|
7
8
|
createWorkflow,
|
|
8
9
|
deleteWorkflow,
|
|
9
10
|
getTaskByWorkflowRunStepId,
|
|
@@ -151,6 +152,28 @@ describe("Workflow Async v2 (Phase 4)", () => {
|
|
|
151
152
|
expect(task!.task).toBe("Do the thing");
|
|
152
153
|
});
|
|
153
154
|
|
|
155
|
+
test("inherits requestedByUserId from workflow execution options", async () => {
|
|
156
|
+
const user = createUser({ name: "Workflow Requester" });
|
|
157
|
+
const workflow = makeWorkflow({
|
|
158
|
+
nodes: [
|
|
159
|
+
{
|
|
160
|
+
id: "task1",
|
|
161
|
+
type: "agent-task",
|
|
162
|
+
config: { template: "Do attributed workflow work" },
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const runId = await startWorkflowExecution(workflow, { test: true }, registry, {
|
|
168
|
+
requestedByUserId: user.id,
|
|
169
|
+
});
|
|
170
|
+
const steps = getWorkflowRunStepsByRunId(runId);
|
|
171
|
+
const task = getTaskByWorkflowRunStepId(steps[0]!.id);
|
|
172
|
+
|
|
173
|
+
expect(task).toBeTruthy();
|
|
174
|
+
expect(task!.requestedByUserId).toBe(user.id);
|
|
175
|
+
});
|
|
176
|
+
|
|
154
177
|
test("workflow pauses at waiting when hitting async executor", async () => {
|
|
155
178
|
const workflow = makeWorkflow({
|
|
156
179
|
nodes: [
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { parseComposioArgs, runComposioCommand, runXCommand } from "../commands/x";
|
|
4
|
+
|
|
5
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
6
|
+
return new Response(JSON.stringify(body), {
|
|
7
|
+
status,
|
|
8
|
+
headers: { "Content-Type": "application/json" },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("parseComposioArgs", () => {
|
|
13
|
+
test("parses method, path, body, query, and base url", () => {
|
|
14
|
+
const parsed = parseComposioArgs(
|
|
15
|
+
[
|
|
16
|
+
"POST",
|
|
17
|
+
"/tools/execute/GITHUB_CREATE_AN_ISSUE",
|
|
18
|
+
"--body",
|
|
19
|
+
'{"arguments":{"title":"demo"}}',
|
|
20
|
+
"--query",
|
|
21
|
+
"preview=true",
|
|
22
|
+
"--base-url",
|
|
23
|
+
"https://example.test/api",
|
|
24
|
+
],
|
|
25
|
+
{},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(parsed.method).toBe("POST");
|
|
29
|
+
expect(parsed.endpoint).toBe("/tools/execute/GITHUB_CREATE_AN_ISSUE");
|
|
30
|
+
expect(parsed.body).toEqual({ arguments: { title: "demo" } });
|
|
31
|
+
expect(parsed.query).toEqual([["preview", "true"]]);
|
|
32
|
+
expect(parsed.baseUrl).toBe("https://example.test/api");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("rejects absolute endpoint URLs so API keys are not routed elsewhere", () => {
|
|
36
|
+
expect(() => parseComposioArgs(["GET", "https://evil.example/tools"], {})).toThrow(
|
|
37
|
+
"endpoint must be a Composio API path",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("runComposioCommand", () => {
|
|
43
|
+
test("routes request to Composio with x-api-key auth and pretty JSON output", async () => {
|
|
44
|
+
const out: string[] = [];
|
|
45
|
+
const fetchMock = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
46
|
+
expect(String(url)).toBe("https://backend.composio.dev/api/v3.1/tools?limit=10");
|
|
47
|
+
expect(init?.method).toBe("GET");
|
|
48
|
+
expect((init?.headers as Record<string, string>)["x-api-key"]).toBe("ck_test_secret");
|
|
49
|
+
return jsonResponse({ ok: true, token: "ck_test_secret" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await runComposioCommand(["GET", "/tools", "--query", "limit=10"], {
|
|
53
|
+
env: { COMPOSIO_API_KEY: "ck_test_secret" },
|
|
54
|
+
fetch: fetchMock,
|
|
55
|
+
log: (message) => out.push(message),
|
|
56
|
+
error: (message) => out.push(message),
|
|
57
|
+
exit: () => {
|
|
58
|
+
throw new Error("should not exit");
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
63
|
+
expect(out.join("\n")).toContain('"ok": true');
|
|
64
|
+
expect(out.join("\n")).toContain("[REDACTED:COMPOSIO_API_KEY]");
|
|
65
|
+
expect(out.join("\n")).not.toContain("ck_test_secret");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("uses org key header when --org is passed", async () => {
|
|
69
|
+
const fetchMock = mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
70
|
+
expect((init?.headers as Record<string, string>)["x-org-api-key"]).toBe("org_secret_value");
|
|
71
|
+
expect((init?.headers as Record<string, string>)["x-api-key"]).toBeUndefined();
|
|
72
|
+
return jsonResponse({ ok: true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await runComposioCommand(["GET", "/org/projects", "--org"], {
|
|
76
|
+
env: { COMPOSIO_ORG_API_KEY: "org_secret_value" },
|
|
77
|
+
fetch: fetchMock,
|
|
78
|
+
log: () => {},
|
|
79
|
+
error: () => {},
|
|
80
|
+
exit: () => {
|
|
81
|
+
throw new Error("should not exit");
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("exits before fetch when COMPOSIO_API_KEY is missing", async () => {
|
|
89
|
+
const err: string[] = [];
|
|
90
|
+
const exit = mock(() => undefined);
|
|
91
|
+
const fetchMock = mock(async () => jsonResponse({ ok: true }));
|
|
92
|
+
|
|
93
|
+
await runComposioCommand(["GET", "/tools"], {
|
|
94
|
+
env: {},
|
|
95
|
+
fetch: fetchMock,
|
|
96
|
+
log: () => {},
|
|
97
|
+
error: (message) => err.push(message),
|
|
98
|
+
exit,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
102
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
103
|
+
expect(err.join("\n")).toContain("COMPOSIO_API_KEY is required");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("runXCommand", () => {
|
|
108
|
+
test("dispatches to composio target", async () => {
|
|
109
|
+
const fetchMock = mock(async () => jsonResponse({ ok: true }));
|
|
110
|
+
await runXCommand(["composio", "GET", "/tools"], {
|
|
111
|
+
env: { COMPOSIO_API_KEY: "ck_test_secret" },
|
|
112
|
+
fetch: fetchMock,
|
|
113
|
+
log: () => {},
|
|
114
|
+
error: () => {},
|
|
115
|
+
exit: () => {
|
|
116
|
+
throw new Error("should not exit");
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
});
|