@customclaw/composio 0.0.7 → 0.0.8

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.
@@ -1,506 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { ComposioClient } from "./client.js";
3
- import { parseComposioConfig } from "./config.js";
4
- import { createComposioExecuteTool } from "./tools/execute.js";
5
- import { createComposioConnectionsTool } from "./tools/connections.js";
6
- // Mock the Composio SDK
7
- vi.mock("@composio/core", () => ({
8
- Composio: vi.fn().mockImplementation(() => ({
9
- toolRouter: {
10
- create: vi.fn().mockResolvedValue({
11
- sessionId: "test-session-123",
12
- tools: vi.fn().mockResolvedValue([]),
13
- authorize: vi.fn().mockResolvedValue({ url: "https://connect.composio.dev/test" }),
14
- toolkits: vi.fn().mockResolvedValue({
15
- items: [
16
- { slug: "gmail", name: "Gmail", connection: { isActive: true } },
17
- { slug: "sentry", name: "Sentry", connection: { isActive: false } },
18
- { slug: "github", name: "GitHub", connection: { isActive: true } },
19
- { slug: "affinity", name: "Affinity", connection: { isActive: false } },
20
- ],
21
- }),
22
- experimental: { assistivePrompt: "" },
23
- }),
24
- },
25
- client: {
26
- tools: {
27
- execute: vi.fn().mockResolvedValue({
28
- successful: true,
29
- data: { results: [{ tool_slug: "GMAIL_FETCH_EMAILS", index: 0, response: { successful: true, data: { messages: [] } } }] },
30
- }),
31
- },
32
- connectedAccounts: {
33
- list: vi.fn().mockResolvedValue({ items: [], next_cursor: null }),
34
- },
35
- },
36
- tools: {
37
- execute: vi.fn().mockResolvedValue({
38
- successful: true,
39
- data: { direct: true },
40
- }),
41
- },
42
- connectedAccounts: {
43
- list: vi.fn().mockResolvedValue({ items: [] }),
44
- get: vi.fn().mockResolvedValue({ toolkit: { slug: "gmail" }, status: "ACTIVE" }),
45
- delete: vi.fn().mockResolvedValue({}),
46
- },
47
- })),
48
- }));
49
- function makeClient(overrides) {
50
- return new ComposioClient({
51
- enabled: true,
52
- apiKey: "test-key",
53
- ...overrides,
54
- });
55
- }
56
- async function getLatestComposioInstance() {
57
- const { Composio } = await import("@composio/core");
58
- const mockResults = Composio.mock.results;
59
- return mockResults[mockResults.length - 1].value;
60
- }
61
- describe("config parsing", () => {
62
- it("reads apiKey from config object", () => {
63
- const config = parseComposioConfig({ config: { apiKey: "from-config" } });
64
- expect(config.apiKey).toBe("from-config");
65
- });
66
- it("reads apiKey from top-level", () => {
67
- const config = parseComposioConfig({ apiKey: "from-top" });
68
- expect(config.apiKey).toBe("from-top");
69
- });
70
- it("falls back to env var", () => {
71
- process.env.COMPOSIO_API_KEY = "from-env";
72
- const config = parseComposioConfig({});
73
- expect(config.apiKey).toBe("from-env");
74
- delete process.env.COMPOSIO_API_KEY;
75
- });
76
- it("defaults enabled to true", () => {
77
- const config = parseComposioConfig({});
78
- expect(config.enabled).toBe(true);
79
- });
80
- it("reads defaultUserId and toolkit filters from nested config object", () => {
81
- const config = parseComposioConfig({
82
- config: {
83
- apiKey: "from-config",
84
- defaultUserId: "app-user-123",
85
- allowedToolkits: ["gmail", "sentry"],
86
- blockedToolkits: ["github"],
87
- },
88
- });
89
- expect(config.defaultUserId).toBe("app-user-123");
90
- expect(config.allowedToolkits).toEqual(["gmail", "sentry"]);
91
- expect(config.blockedToolkits).toEqual(["github"]);
92
- });
93
- });
94
- describe("toolkit filtering", () => {
95
- it("allows all toolkits when no filter set", async () => {
96
- const client = makeClient();
97
- const statuses = await client.getConnectionStatus(["gmail", "sentry", "github"]);
98
- expect(statuses).toHaveLength(3);
99
- });
100
- it("filters by allowedToolkits", async () => {
101
- const client = makeClient({ allowedToolkits: ["gmail", "sentry"] });
102
- const statuses = await client.getConnectionStatus(["gmail", "sentry", "github"]);
103
- expect(statuses).toHaveLength(2);
104
- expect(statuses.map(s => s.toolkit)).toEqual(["gmail", "sentry"]);
105
- });
106
- it("filters by blockedToolkits", async () => {
107
- const client = makeClient({ blockedToolkits: ["github"] });
108
- const statuses = await client.getConnectionStatus(["gmail", "sentry", "github"]);
109
- expect(statuses).toHaveLength(2);
110
- expect(statuses.find(s => s.toolkit === "github")).toBeUndefined();
111
- });
112
- it("blocked takes priority over allowed", async () => {
113
- const client = makeClient({ allowedToolkits: ["gmail", "github"], blockedToolkits: ["github"] });
114
- const statuses = await client.getConnectionStatus(["gmail", "github"]);
115
- expect(statuses).toHaveLength(1);
116
- expect(statuses[0].toolkit).toBe("gmail");
117
- });
118
- });
119
- describe("connection status", () => {
120
- it("reports gmail as connected", async () => {
121
- const client = makeClient();
122
- const statuses = await client.getConnectionStatus(["gmail"]);
123
- expect(statuses[0].connected).toBe(true);
124
- });
125
- it("reports sentry as not connected", async () => {
126
- const client = makeClient();
127
- const statuses = await client.getConnectionStatus(["sentry"]);
128
- expect(statuses[0].connected).toBe(false);
129
- });
130
- it("reports toolkit as connected when active connected account exists", async () => {
131
- const client = makeClient();
132
- const instance = await getLatestComposioInstance();
133
- instance.connectedAccounts.list.mockResolvedValueOnce({
134
- items: [{ toolkit: { slug: "affinity" }, status: "ACTIVE" }],
135
- nextCursor: null,
136
- });
137
- const statuses = await client.getConnectionStatus(["affinity"]);
138
- expect(statuses[0].connected).toBe(true);
139
- });
140
- it("returns only connected toolkits when no filter", async () => {
141
- const client = makeClient();
142
- const statuses = await client.getConnectionStatus();
143
- expect(statuses.every(s => s.connected)).toBe(true);
144
- expect(statuses.map(s => s.toolkit)).toEqual(["gmail", "github"]);
145
- });
146
- });
147
- describe("execute tool", () => {
148
- it("executes and returns result", async () => {
149
- const client = makeClient();
150
- const result = await client.executeTool("GMAIL_FETCH_EMAILS", {});
151
- expect(result.success).toBe(true);
152
- expect(result.data).toEqual({ messages: [] });
153
- });
154
- it("rejects blocked toolkit", async () => {
155
- const client = makeClient({ allowedToolkits: ["sentry"] });
156
- const result = await client.executeTool("GMAIL_FETCH_EMAILS", {});
157
- expect(result.success).toBe(false);
158
- expect(result.error).toContain("not allowed");
159
- });
160
- it("pins execution to explicit connected_account_id", async () => {
161
- const client = makeClient();
162
- const instance = await getLatestComposioInstance();
163
- instance.connectedAccounts.get.mockResolvedValueOnce({
164
- toolkit: { slug: "gmail" },
165
- status: "ACTIVE",
166
- });
167
- const result = await client.executeTool("GMAIL_FETCH_EMAILS", {}, "default", "ca_explicit");
168
- expect(result.success).toBe(true);
169
- expect(instance.toolRouter.create).toHaveBeenCalledWith("default", {
170
- connectedAccounts: { gmail: "ca_explicit" },
171
- });
172
- });
173
- it("auto-pins execution when one active account exists", async () => {
174
- const client = makeClient();
175
- const instance = await getLatestComposioInstance();
176
- instance.client.connectedAccounts.list.mockResolvedValueOnce({
177
- items: [
178
- { id: "ca_single", user_id: "default", status: "ACTIVE", toolkit: { slug: "gmail" } },
179
- ],
180
- next_cursor: null,
181
- });
182
- const result = await client.executeTool("GMAIL_FETCH_EMAILS", {}, "default");
183
- expect(result.success).toBe(true);
184
- expect(instance.toolRouter.create).toHaveBeenCalledWith("default", {
185
- connectedAccounts: { gmail: "ca_single" },
186
- });
187
- });
188
- it("fails with clear error when multiple active accounts exist and none selected", async () => {
189
- const client = makeClient();
190
- const instance = await getLatestComposioInstance();
191
- instance.client.connectedAccounts.list.mockResolvedValueOnce({
192
- items: [
193
- { id: "ca_1", user_id: "default", status: "ACTIVE", toolkit: { slug: "gmail" } },
194
- { id: "ca_2", user_id: "default", status: "ACTIVE", toolkit: { slug: "gmail" } },
195
- ],
196
- next_cursor: null,
197
- });
198
- const result = await client.executeTool("GMAIL_FETCH_EMAILS", {}, "default");
199
- expect(result.success).toBe(false);
200
- expect(result.error).toContain("Multiple ACTIVE 'gmail' accounts");
201
- expect(result.error).toContain("ca_1");
202
- expect(result.error).toContain("ca_2");
203
- });
204
- it("falls back to direct execute when meta-tool resolves entity as default for non-default user", async () => {
205
- const client = makeClient();
206
- const instance = await getLatestComposioInstance();
207
- instance.client.connectedAccounts.list.mockResolvedValueOnce({
208
- items: [
209
- { id: "ca_sentry", user_id: "pg-user", status: "ACTIVE", toolkit: { slug: "sentry" } },
210
- ],
211
- next_cursor: null,
212
- });
213
- instance.client.tools.execute.mockResolvedValueOnce({
214
- successful: false,
215
- error: "1 out of 1 tools failed",
216
- data: {
217
- results: [{ error: "Error: No connected account found for entity ID default for toolkit sentry" }],
218
- },
219
- });
220
- instance.tools.execute.mockResolvedValueOnce({
221
- successful: true,
222
- data: { ok: true },
223
- });
224
- const result = await client.executeTool("SENTRY_GET_ORGANIZATION_DETAILS", {}, "pg-user");
225
- expect(result.success).toBe(true);
226
- expect(result.data).toEqual({ ok: true });
227
- expect(instance.tools.execute).toHaveBeenCalledWith("SENTRY_GET_ORGANIZATION_DETAILS", {
228
- userId: "pg-user",
229
- connectedAccountId: "ca_sentry",
230
- arguments: {},
231
- dangerouslySkipVersionCheck: true,
232
- });
233
- });
234
- it("retries once with server-hinted identifier value", async () => {
235
- const client = makeClient();
236
- const instance = await getLatestComposioInstance();
237
- instance.client.connectedAccounts.list.mockResolvedValueOnce({
238
- items: [
239
- { id: "ca_posthog", user_id: "pg-user", status: "ACTIVE", toolkit: { slug: "posthog" } },
240
- ],
241
- next_cursor: null,
242
- });
243
- instance.client.tools.execute.mockResolvedValueOnce({
244
- successful: true,
245
- data: {
246
- results: [
247
- {
248
- tool_slug: "POSTHOG_RETRIEVE_USER_PROFILE_AND_TEAM_DETAILS",
249
- index: 0,
250
- response: {
251
- successful: false,
252
- error: JSON.stringify({
253
- type: "authentication_error",
254
- code: "permission_denied",
255
- detail: "As a non-staff user you're only allowed to access the `@me` user instance.",
256
- attr: null,
257
- }),
258
- },
259
- },
260
- ],
261
- },
262
- });
263
- instance.tools.execute.mockResolvedValueOnce({
264
- successful: true,
265
- data: { ok: true, retried: true },
266
- });
267
- const result = await client.executeTool("POSTHOG_RETRIEVE_USER_PROFILE_AND_TEAM_DETAILS", { uuid: "some-other-uuid" }, "pg-user");
268
- expect(result.success).toBe(true);
269
- expect(result.data).toEqual({ ok: true, retried: true });
270
- expect(instance.tools.execute).toHaveBeenCalledWith("POSTHOG_RETRIEVE_USER_PROFILE_AND_TEAM_DETAILS", {
271
- userId: "pg-user",
272
- connectedAccountId: "ca_posthog",
273
- arguments: { uuid: "@me" },
274
- dangerouslySkipVersionCheck: true,
275
- });
276
- });
277
- });
278
- describe("create connection", () => {
279
- it("returns auth URL", async () => {
280
- const client = makeClient();
281
- const result = await client.createConnection("gmail");
282
- expect("authUrl" in result).toBe(true);
283
- if ("authUrl" in result) {
284
- expect(result.authUrl).toContain("connect.composio.dev");
285
- }
286
- });
287
- it("rejects blocked toolkit", async () => {
288
- const client = makeClient({ blockedToolkits: ["gmail"] });
289
- const result = await client.createConnection("gmail");
290
- expect("error" in result).toBe(true);
291
- });
292
- });
293
- describe("disconnect toolkit", () => {
294
- it("disconnects single active account", async () => {
295
- const client = makeClient();
296
- const instance = await getLatestComposioInstance();
297
- instance.client.connectedAccounts.list.mockResolvedValueOnce({
298
- items: [
299
- { id: "ca_gmail", user_id: "default", status: "ACTIVE", toolkit: { slug: "gmail" } },
300
- ],
301
- next_cursor: null,
302
- });
303
- const result = await client.disconnectToolkit("gmail", "default");
304
- expect(result.success).toBe(true);
305
- expect(instance.connectedAccounts.delete).toHaveBeenCalledWith({ connectedAccountId: "ca_gmail" });
306
- });
307
- it("fails safely when multiple active accounts exist", async () => {
308
- const client = makeClient();
309
- const instance = await getLatestComposioInstance();
310
- instance.client.connectedAccounts.list.mockResolvedValueOnce({
311
- items: [
312
- { id: "ca_1", user_id: "default", status: "ACTIVE", toolkit: { slug: "gmail" } },
313
- { id: "ca_2", user_id: "default", status: "ACTIVE", toolkit: { slug: "gmail" } },
314
- ],
315
- next_cursor: null,
316
- });
317
- const result = await client.disconnectToolkit("gmail", "default");
318
- expect(result.success).toBe(false);
319
- expect(result.error).toContain("Multiple ACTIVE 'gmail' accounts");
320
- expect(instance.connectedAccounts.delete).not.toHaveBeenCalled();
321
- });
322
- });
323
- describe("connected accounts discovery", () => {
324
- it("lists connected accounts with user IDs from raw API", async () => {
325
- const client = makeClient();
326
- const instance = await getLatestComposioInstance();
327
- instance.client.connectedAccounts.list.mockResolvedValueOnce({
328
- items: [
329
- {
330
- id: "ca_1",
331
- user_id: "user-a",
332
- status: "ACTIVE",
333
- toolkit: { slug: "sentry" },
334
- auth_config: { id: "ac_1" },
335
- },
336
- ],
337
- next_cursor: null,
338
- });
339
- const accounts = await client.listConnectedAccounts({ toolkits: ["sentry"], statuses: ["ACTIVE"] });
340
- expect(instance.client.connectedAccounts.list).toHaveBeenCalledWith({
341
- toolkit_slugs: ["sentry"],
342
- statuses: ["ACTIVE"],
343
- limit: 100,
344
- });
345
- expect(accounts).toEqual([
346
- {
347
- id: "ca_1",
348
- toolkit: "sentry",
349
- userId: "user-a",
350
- status: "ACTIVE",
351
- authConfigId: "ac_1",
352
- isDisabled: undefined,
353
- createdAt: undefined,
354
- updatedAt: undefined,
355
- },
356
- ]);
357
- });
358
- it("falls back to SDK-normalized account list when raw API errors", async () => {
359
- const client = makeClient();
360
- const instance = await getLatestComposioInstance();
361
- instance.client.connectedAccounts.list.mockRejectedValueOnce(new Error("raw unavailable"));
362
- instance.connectedAccounts.list.mockResolvedValueOnce({
363
- items: [
364
- {
365
- id: "ca_2",
366
- status: "ACTIVE",
367
- toolkit: { slug: "gmail" },
368
- authConfig: { id: "ac_2" },
369
- isDisabled: false,
370
- },
371
- ],
372
- nextCursor: null,
373
- });
374
- const accounts = await client.listConnectedAccounts({ toolkits: ["gmail"], statuses: ["ACTIVE"] });
375
- expect(accounts[0]).toMatchObject({
376
- id: "ca_2",
377
- toolkit: "gmail",
378
- status: "ACTIVE",
379
- authConfigId: "ac_2",
380
- isDisabled: false,
381
- });
382
- });
383
- it("finds active user IDs for toolkit", async () => {
384
- const client = makeClient();
385
- const instance = await getLatestComposioInstance();
386
- instance.client.connectedAccounts.list.mockResolvedValueOnce({
387
- items: [
388
- { id: "ca_1", user_id: "default", status: "ACTIVE", toolkit: { slug: "sentry" } },
389
- { id: "ca_2", user_id: "user-b", status: "ACTIVE", toolkit: { slug: "sentry" } },
390
- { id: "ca_3", user_id: "default", status: "ACTIVE", toolkit: { slug: "sentry" } },
391
- ],
392
- next_cursor: null,
393
- });
394
- const userIds = await client.findActiveUserIdsForToolkit("sentry");
395
- expect(userIds).toEqual(["default", "user-b"]);
396
- });
397
- });
398
- describe("session caching", () => {
399
- it("reuses session for same user", async () => {
400
- const client = makeClient();
401
- await client.getConnectionStatus(["gmail"]);
402
- await client.getConnectionStatus(["gmail"]);
403
- // toolRouter.create should only be called once
404
- const { Composio } = await import("@composio/core");
405
- const instance = Composio.mock.results[0].value;
406
- expect(instance.toolRouter.create).toHaveBeenCalledTimes(1);
407
- });
408
- });
409
- describe("execute tool string arguments (GLM-5 workaround)", () => {
410
- function makeTool() {
411
- const client = makeClient();
412
- const config = parseComposioConfig({ config: { apiKey: "test-key" } });
413
- return createComposioExecuteTool(client, config);
414
- }
415
- it("parses string arguments as JSON", async () => {
416
- const tool = makeTool();
417
- const result = await tool.execute("test", {
418
- tool_slug: "GMAIL_FETCH_EMAILS",
419
- arguments: '{"user_id": "me", "max_results": 5}',
420
- });
421
- expect(result.details).toHaveProperty("success", true);
422
- });
423
- it("handles object arguments normally", async () => {
424
- const tool = makeTool();
425
- const result = await tool.execute("test", {
426
- tool_slug: "GMAIL_FETCH_EMAILS",
427
- arguments: { user_id: "me", max_results: 5 },
428
- });
429
- expect(result.details).toHaveProperty("success", true);
430
- });
431
- it("falls back to empty args on invalid JSON string", async () => {
432
- const tool = makeTool();
433
- const result = await tool.execute("test", {
434
- tool_slug: "GMAIL_FETCH_EMAILS",
435
- arguments: "not valid json",
436
- });
437
- expect(result.details).toHaveProperty("success", true);
438
- });
439
- it("falls back to empty args when arguments is missing", async () => {
440
- const tool = makeTool();
441
- const result = await tool.execute("test", {
442
- tool_slug: "GMAIL_FETCH_EMAILS",
443
- });
444
- expect(result.details).toHaveProperty("success", true);
445
- });
446
- });
447
- describe("connections tool", () => {
448
- function makeConnectionsTool() {
449
- const client = makeClient();
450
- const config = parseComposioConfig({ config: { apiKey: "test-key" } });
451
- return createComposioConnectionsTool(client, config);
452
- }
453
- it("list action passes user_id to client", async () => {
454
- const tool = makeConnectionsTool();
455
- await tool.execute("test", { action: "list", user_id: "custom-user" });
456
- const instance = await getLatestComposioInstance();
457
- expect(instance.toolRouter.create).toHaveBeenCalledWith("custom-user", undefined);
458
- });
459
- it("status uses active connected accounts as fallback", async () => {
460
- const tool = makeConnectionsTool();
461
- const instance = await getLatestComposioInstance();
462
- instance.connectedAccounts.list.mockResolvedValueOnce({
463
- items: [{ toolkit: { slug: "affinity" }, status: "ACTIVE" }],
464
- nextCursor: null,
465
- });
466
- const result = await tool.execute("test", { action: "status", toolkit: "affinity" });
467
- const details = result.details;
468
- const conn = details.connections.find((c) => c.toolkit === "affinity");
469
- expect(conn.connected).toBe(true);
470
- expect(instance.client.tools.execute).not.toHaveBeenCalledWith("AFFINITY_GET_METADATA_ON_ALL_LISTS", expect.anything());
471
- });
472
- it("status keeps disconnected when no active account exists", async () => {
473
- const tool = makeConnectionsTool();
474
- const result = await tool.execute("test", { action: "status", toolkit: "sentry" });
475
- const details = result.details;
476
- const conn = details.connections.find((c) => c.toolkit === "sentry");
477
- expect(conn.connected).toBe(false);
478
- });
479
- it("accounts action returns connected accounts", async () => {
480
- const tool = makeConnectionsTool();
481
- const instance = await getLatestComposioInstance();
482
- instance.client.connectedAccounts.list.mockResolvedValueOnce({
483
- items: [
484
- {
485
- id: "ca_1",
486
- user_id: "user-a",
487
- status: "ACTIVE",
488
- toolkit: { slug: "sentry" },
489
- auth_config: { id: "ac_1" },
490
- },
491
- ],
492
- next_cursor: null,
493
- });
494
- const result = await tool.execute("test", { action: "accounts", toolkit: "sentry" });
495
- const details = result.details;
496
- expect(details.action).toBe("accounts");
497
- expect(details.count).toBe(1);
498
- expect(details.accounts[0]).toMatchObject({
499
- id: "ca_1",
500
- toolkit: "sentry",
501
- user_id: "user-a",
502
- status: "ACTIVE",
503
- auth_config_id: "ac_1",
504
- });
505
- });
506
- });