2465 lines
78 KiB
TypeScript
2465 lines
78 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { mockClient } from '../utils/mocks/client.mock';
|
|
import { toolMocks } from '../utils/mocks/data.mock';
|
|
import { Tool, ToolListParams, ToolExecuteParams } from '../../src/types/tool.types';
|
|
import { Tools } from '../../src/models/Tools';
|
|
import ComposioClient from '@composio/client';
|
|
import {
|
|
createTestContext,
|
|
setupTest,
|
|
mockToolExecution,
|
|
createSchemaModifier,
|
|
} from '../utils/toolExecuteUtils';
|
|
import { MockProvider } from '../utils/mocks/provider.mock';
|
|
import { ValidationError } from '../../src/errors/ValidationErrors';
|
|
|
|
describe('Tools', () => {
|
|
const context = createTestContext();
|
|
setupTest(context);
|
|
|
|
describe('constructor', () => {
|
|
it('should throw an error if client is not provided', () => {
|
|
expect(() => new Tools(null as any, { provider: context.mockProvider })).toThrow(
|
|
'ComposioClient is required'
|
|
);
|
|
});
|
|
|
|
it('should throw an error if provider is not provided', () => {
|
|
expect(() => new Tools(mockClient as unknown as ComposioClient, null as any)).toThrow(
|
|
'Provider not passed into Tools instance'
|
|
);
|
|
});
|
|
|
|
it('should create an instance successfully with valid parameters', () => {
|
|
expect(context.tools).toBeInstanceOf(Tools);
|
|
});
|
|
});
|
|
|
|
describe('getRawComposioTools', () => {
|
|
it('should fetch tools from the API', async () => {
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
const result = await context.tools.getRawComposioTools({ tools: ['TEST_TOOL'] });
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledTimes(1);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].slug).toEqual(toolMocks.transformedTool.slug);
|
|
expect(result[0].inputParameters).toBeDefined();
|
|
});
|
|
|
|
it('should handle query parameters correctly', async () => {
|
|
const query = {
|
|
tools: ['TOOL1', 'TOOL2'],
|
|
};
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await context.tools.getRawComposioTools(query);
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
tool_slugs: 'TOOL1,TOOL2',
|
|
limit: 9999,
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle toolkit query parameters correctly', async () => {
|
|
const query = {
|
|
toolkits: ['github'],
|
|
};
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await context.tools.getRawComposioTools(query);
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
important: 'true',
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should respect explicit important=false to opt-out', async () => {
|
|
const query = {
|
|
toolkits: ['github'],
|
|
important: false,
|
|
limit: 10,
|
|
};
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await context.tools.getRawComposioTools(query);
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
limit: 10,
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle toolkit search parameters correctly', async () => {
|
|
const query = {
|
|
toolkits: ['github'],
|
|
search: 'test',
|
|
limit: 10,
|
|
};
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await context.tools.getRawComposioTools(query);
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
limit: 10,
|
|
search: 'test',
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle toolkit scopes parameters correctly', async () => {
|
|
const query: ToolListParams = {
|
|
toolkits: ['todoist'],
|
|
scopes: ['task:add', 'task:read'],
|
|
};
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await context.tools.getRawComposioTools(query);
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'todoist',
|
|
important: 'true',
|
|
scopes: ['task:add', 'task:read'],
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle toolkit scopes with search parameters correctly', async () => {
|
|
const query: ToolListParams = {
|
|
toolkits: ['todoist'],
|
|
scopes: ['task:add'],
|
|
search: 'add task',
|
|
limit: 10,
|
|
};
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await context.tools.getRawComposioTools(query);
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'todoist',
|
|
limit: 10,
|
|
search: 'add task',
|
|
scopes: ['task:add'],
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should respect explicit important=true', async () => {
|
|
const query: ToolListParams = {
|
|
toolkits: ['github'],
|
|
important: true,
|
|
limit: 10,
|
|
};
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await context.tools.getRawComposioTools(query);
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
important: 'true',
|
|
limit: 10,
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should NOT auto-apply important when tags are passed with values', async () => {
|
|
const query: ToolListParams = {
|
|
toolkits: ['github'],
|
|
tags: ['important', 'custom'],
|
|
limit: 10,
|
|
};
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await context.tools.getRawComposioTools(query);
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
tags: ['important', 'custom'],
|
|
limit: 10,
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
// Should NOT include important: 'true' when tags are provided
|
|
});
|
|
|
|
it('should NOT auto-apply important when tags are passed as empty array', async () => {
|
|
const query: ToolListParams = {
|
|
toolkits: ['github'],
|
|
tags: [],
|
|
};
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await context.tools.getRawComposioTools(query);
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
tags: [],
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
// Should NOT include important: 'true' when tags array is present (even if empty)
|
|
});
|
|
|
|
it('should NOT auto-apply important when limit is provided', async () => {
|
|
const query: ToolListParams = {
|
|
toolkits: ['github'],
|
|
limit: 10,
|
|
};
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await context.tools.getRawComposioTools(query);
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
limit: 10,
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
// Should NOT include important: 'true' when limit is provided
|
|
});
|
|
|
|
it('should throw a validation error when scopes are provided without toolkits', async () => {
|
|
const invalidQuery = {
|
|
scopes: ['task:add'],
|
|
} as any;
|
|
|
|
await expect(context.tools.getRawComposioTools(invalidQuery)).rejects.toThrow(
|
|
'Invalid tool list parameters'
|
|
);
|
|
});
|
|
|
|
it('should transform tool case correctly', async () => {
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
const result = await context.tools.getRawComposioTools({ tools: ['TEST_TOOL'] });
|
|
|
|
expect(result[0].inputParameters).toEqual(toolMocks.transformedTool.inputParameters);
|
|
expect(result[0].outputParameters).toEqual(toolMocks.transformedTool.outputParameters);
|
|
});
|
|
|
|
it('should apply schema modifiers when provided', async () => {
|
|
const schemaModifier = createSchemaModifier({
|
|
description: 'Modified description',
|
|
});
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
const result = await context.tools.getRawComposioTools(
|
|
{ tools: ['TEST_TOOL'] },
|
|
{ modifySchema: schemaModifier }
|
|
);
|
|
|
|
expect(schemaModifier).toHaveBeenCalled();
|
|
expect(result[0].description).toEqual('Modified description');
|
|
});
|
|
|
|
it('should throw an error if schema modifier is not a function', async () => {
|
|
const invalidModifier = 'not a function' as any;
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await expect(
|
|
context.tools.getRawComposioTools(
|
|
{
|
|
toolkits: ['invalid'],
|
|
},
|
|
{ modifySchema: invalidModifier }
|
|
)
|
|
).rejects.toThrow('Invalid schema modifier. Not a function.');
|
|
});
|
|
|
|
it('should throw a validation error when both tools and toolkits are provided', async () => {
|
|
const invalidQuery = {
|
|
tools: ['TOOL1'],
|
|
toolkits: ['github'],
|
|
} as any;
|
|
|
|
await expect(context.tools.getRawComposioTools(invalidQuery)).rejects.toThrow(
|
|
'Invalid tool list parameters'
|
|
);
|
|
});
|
|
|
|
it('should throw a validation error when no required parameters are provided', async () => {
|
|
const emptyQuery = {} as any;
|
|
|
|
await expect(context.tools.getRawComposioTools(emptyQuery)).rejects.toThrow(ValidationError);
|
|
});
|
|
});
|
|
|
|
describe('getRawToolRouterSessionTools', () => {
|
|
it('should fetch all paginated session tool pages', async () => {
|
|
mockClient.toolRouter.session.tools
|
|
.mockResolvedValueOnce({
|
|
items: [{ ...toolMocks.rawTool, slug: 'FIRST_TOOL' }],
|
|
next_cursor: 'next_page',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
items: [{ ...toolMocks.rawTool, slug: 'SECOND_TOOL' }],
|
|
next_cursor: null,
|
|
});
|
|
|
|
const result = await context.tools.getRawToolRouterSessionTools('session_123');
|
|
|
|
expect(result.map(tool => tool.slug)).toEqual(['FIRST_TOOL', 'SECOND_TOOL']);
|
|
expect(mockClient.toolRouter.session.tools).toHaveBeenNthCalledWith(
|
|
1,
|
|
'session_123',
|
|
{
|
|
limit: 500,
|
|
},
|
|
undefined
|
|
);
|
|
expect(mockClient.toolRouter.session.tools).toHaveBeenNthCalledWith(
|
|
2,
|
|
'session_123',
|
|
{
|
|
limit: 500,
|
|
cursor: 'next_page',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getRawComposioToolBySlug', () => {
|
|
it('should fetch a tool by slug from the API', async () => {
|
|
const slug = 'TOOL_SLUG';
|
|
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.rawTool);
|
|
|
|
const result = await context.tools.getRawComposioToolBySlug(slug);
|
|
|
|
expect(mockClient.tools.retrieve).toHaveBeenCalledWith(
|
|
slug,
|
|
{ toolkit_versions: 'latest' },
|
|
undefined
|
|
);
|
|
expect(result.slug).toEqual(toolMocks.transformedTool.slug);
|
|
});
|
|
|
|
it('should throw an error if tool is not found', async () => {
|
|
const slug = 'NONEXISTENT_TOOL';
|
|
|
|
mockClient.tools.retrieve.mockRejectedValue(null);
|
|
|
|
await expect(context.tools.getRawComposioToolBySlug(slug)).rejects.toThrow(
|
|
`Unable to retrieve tool with slug ${slug}`
|
|
);
|
|
});
|
|
|
|
it('should apply schema modifiers when provided', async () => {
|
|
const slug = 'TOOL_SLUG';
|
|
const schemaModifier = createSchemaModifier({
|
|
description: 'Modified description',
|
|
});
|
|
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.rawTool);
|
|
|
|
const result = await context.tools.getRawComposioToolBySlug(slug, {
|
|
modifySchema: schemaModifier,
|
|
});
|
|
|
|
expect(schemaModifier).toHaveBeenCalled();
|
|
expect(result.description).toEqual('Modified description');
|
|
});
|
|
|
|
it('should use version parameter when provided (explicit version string)', async () => {
|
|
const slug = 'GITHUB_CREATE_ISSUE';
|
|
const explicitVersion = '20250909_00';
|
|
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.rawTool);
|
|
|
|
await context.tools.getRawComposioToolBySlug(slug, {
|
|
version: explicitVersion,
|
|
});
|
|
|
|
// Should use 'version' param, not 'toolkit_versions'
|
|
expect(mockClient.tools.retrieve).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
version: explicitVersion,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should use version parameter when provided (latest string)', async () => {
|
|
const slug = 'GITHUB_CREATE_ISSUE';
|
|
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.rawTool);
|
|
|
|
await context.tools.getRawComposioToolBySlug(slug, {
|
|
version: 'latest',
|
|
});
|
|
|
|
// Should use 'version' param with 'latest'
|
|
expect(mockClient.tools.retrieve).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
version: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should use version parameter when provided (version mapping object)', async () => {
|
|
const slug = 'GITHUB_CREATE_ISSUE';
|
|
const versionMapping = { github: '20250909_00', slack: 'latest' };
|
|
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.rawTool);
|
|
|
|
// Create a Tools instance with SDK-level toolkitVersions as mapping object
|
|
const toolsWithVersions = new Tools(mockClient as any, {
|
|
provider: context.mockProvider,
|
|
toolkitVersions: versionMapping,
|
|
});
|
|
|
|
await toolsWithVersions.getRawComposioToolBySlug(slug);
|
|
|
|
// Should use 'toolkit_versions' param with mapping object (not 'version')
|
|
expect(mockClient.tools.retrieve).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
toolkit_versions: versionMapping,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should fallback to SDK toolkitVersions when version not provided', async () => {
|
|
const slug = 'GITHUB_CREATE_ISSUE';
|
|
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.rawTool);
|
|
|
|
// Call without version option
|
|
await context.tools.getRawComposioToolBySlug(slug);
|
|
|
|
// Should use 'toolkit_versions' param with SDK-level config
|
|
expect(mockClient.tools.retrieve).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should prioritize explicit version over SDK toolkitVersions', async () => {
|
|
const slug = 'GITHUB_CREATE_ISSUE';
|
|
const explicitVersion = '20250909_00';
|
|
|
|
// Create a Tools instance with SDK-level toolkitVersions
|
|
const toolsWithVersions = new Tools(mockClient as any, {
|
|
provider: context.mockProvider,
|
|
toolkitVersions: { github: explicitVersion },
|
|
});
|
|
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.rawTool);
|
|
|
|
await toolsWithVersions.getRawComposioToolBySlug(slug, {
|
|
version: explicitVersion,
|
|
});
|
|
|
|
// Explicit version should take precedence, using 'version' param
|
|
expect(mockClient.tools.retrieve).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
version: explicitVersion,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should support combining version and modifySchema options', async () => {
|
|
const slug = 'GITHUB_CREATE_ISSUE';
|
|
const explicitVersion = '20250909_00';
|
|
const schemaModifier = createSchemaModifier({
|
|
description: 'Modified description',
|
|
});
|
|
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.rawTool);
|
|
|
|
const result = await context.tools.getRawComposioToolBySlug(slug, {
|
|
version: explicitVersion,
|
|
modifySchema: schemaModifier,
|
|
});
|
|
|
|
// Should use version param
|
|
expect(mockClient.tools.retrieve).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
version: explicitVersion,
|
|
},
|
|
undefined
|
|
);
|
|
// Should also apply schema modifier
|
|
expect(schemaModifier).toHaveBeenCalled();
|
|
expect(result.description).toEqual('Modified description');
|
|
});
|
|
});
|
|
|
|
// MCP-backed toolkits (granola_mcp, apify_mcp, tavily_mcp, …) have no
|
|
// declared output schema, so the Composio API returns
|
|
// output_parameters: {}. Before the fix, transformToolCases ran that
|
|
// through ParametersSchema (which requires { type: 'object', properties:
|
|
// {...} }) and threw a ZodError, breaking every tools.execute /
|
|
// tools.list call for these toolkits. See
|
|
// https://github.com/ComposioHQ/composio/issues/3354.
|
|
describe('MCP-shaped tool metadata', () => {
|
|
it('should normalize output_parameters: {} to undefined on the transformed Tool', async () => {
|
|
const slug = 'GRANOLA_MCP_LIST_MEETINGS';
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.mcpRawTool);
|
|
|
|
const tool = await context.tools.getRawComposioToolBySlug(slug);
|
|
|
|
expect(tool.outputParameters).toBeUndefined();
|
|
expect(tool.inputParameters).toEqual(toolMocks.mcpRawTool.input_parameters);
|
|
});
|
|
|
|
it('should normalize both empty input and output parameters to undefined', async () => {
|
|
const slug = 'SOME_MCP_PING';
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.mcpRawToolBothEmpty);
|
|
|
|
const tool = await context.tools.getRawComposioToolBySlug(slug);
|
|
|
|
expect(tool.inputParameters).toBeUndefined();
|
|
expect(tool.outputParameters).toBeUndefined();
|
|
});
|
|
|
|
it('should also tolerate empty output_parameters in tools.list responses', async () => {
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.mcpRawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
const result = await context.tools.getRawComposioTools({ toolkits: ['granola_mcp'] });
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].slug).toEqual('GRANOLA_MCP_LIST_MEETINGS');
|
|
expect(result[0].outputParameters).toBeUndefined();
|
|
expect(result[0].inputParameters).toEqual(toolMocks.mcpRawTool.input_parameters);
|
|
});
|
|
|
|
it('should execute an MCP tool whose metadata has empty output_parameters', async () => {
|
|
// End-to-end: tools.execute → getRawComposioToolBySlug →
|
|
// transformToolCases → executeComposioTool → client.tools.execute.
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.mcpRawTool);
|
|
mockClient.tools.execute.mockResolvedValueOnce(toolMocks.rawToolExecuteResponse);
|
|
|
|
const result = await context.tools.execute('GRANOLA_MCP_LIST_MEETINGS', {
|
|
userId: 'pg-test-9f4d0df7-a59d-4341-ad00-887b2c58004b',
|
|
arguments: { time_range: 'last_30_days' },
|
|
version: '20260206_00',
|
|
});
|
|
|
|
expect(mockClient.tools.execute).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual(toolMocks.toolExecuteResponse);
|
|
});
|
|
});
|
|
|
|
// `ParametersSchema` is a strict `z.object`, so any key it doesn't declare is
|
|
// dropped on parse. It previously omitted `$defs`/`definitions`, so a tool
|
|
// whose parameters root carries those blocks (the `$ref` targets) lost them —
|
|
// leaving every nested `$ref` dangling and unresolvable for providers and the
|
|
// file modifier. See https://github.com/ComposioHQ/composio/issues/3506.
|
|
describe('$ref / $defs preservation on tool parameters', () => {
|
|
const rawToolWithDefs = {
|
|
slug: 'GMAIL_GET_ATTACHMENT',
|
|
name: 'Get Attachment',
|
|
description: 'Fetch a Gmail attachment',
|
|
input_parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
message_id: { type: 'string' },
|
|
},
|
|
},
|
|
output_parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
data: { $ref: '#/$defs/GetAttachmentResponse' },
|
|
legacy_data: { $ref: '#/definitions/LegacyAttachmentResponse' },
|
|
},
|
|
$defs: {
|
|
GetAttachmentResponse: {
|
|
type: 'object',
|
|
properties: { file: { $ref: '#/$defs/FileDownloadable' } },
|
|
},
|
|
FileDownloadable: {
|
|
type: 'object',
|
|
file_downloadable: true,
|
|
properties: { s3url: { type: 'string' } },
|
|
},
|
|
},
|
|
definitions: {
|
|
LegacyAttachmentResponse: {
|
|
type: 'object',
|
|
properties: { file: { $ref: '#/definitions/LegacyFileDownloadable' } },
|
|
},
|
|
LegacyFileDownloadable: {
|
|
type: 'object',
|
|
file_downloadable: true,
|
|
properties: { s3url: { type: 'string' } },
|
|
},
|
|
},
|
|
},
|
|
toolkit: { logo: 'https://example.com/gmail.png', slug: 'gmail', name: 'Gmail' },
|
|
version: '20260515_00',
|
|
};
|
|
|
|
it('keeps the root $defs block so nested $ref pointers stay resolvable', async () => {
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(rawToolWithDefs);
|
|
|
|
const tool = await context.tools.getRawComposioToolBySlug('GMAIL_GET_ATTACHMENT');
|
|
|
|
expect(tool.outputParameters?.$defs).toBeDefined();
|
|
expect(tool.outputParameters?.$defs?.FileDownloadable).toMatchObject({
|
|
file_downloadable: true,
|
|
});
|
|
expect(tool.outputParameters?.definitions?.LegacyFileDownloadable).toMatchObject({
|
|
file_downloadable: true,
|
|
});
|
|
expect(tool.outputParameters?.properties?.data).toEqual({
|
|
$ref: '#/$defs/GetAttachmentResponse',
|
|
});
|
|
expect(tool.outputParameters?.properties?.legacy_data).toEqual({
|
|
$ref: '#/definitions/LegacyAttachmentResponse',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('get', () => {
|
|
it('should get a single tool by slug and wrap it with provider as a collection', async () => {
|
|
const userId = 'test-user';
|
|
const slug = 'TOOL_SLUG';
|
|
|
|
const getRawComposioToolBySlugSpy = vi.spyOn(context.tools, 'getRawComposioToolBySlug');
|
|
getRawComposioToolBySlugSpy.mockResolvedValueOnce(
|
|
toolMocks.transformedTool as unknown as Tool
|
|
);
|
|
|
|
context.mockProvider.wrapTools.mockReturnValueOnce('wrapped-tools-collection');
|
|
|
|
const result = await context.tools.get(userId, slug);
|
|
|
|
expect(getRawComposioToolBySlugSpy).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
modifySchema: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
expect(context.mockProvider.wrapTools).toHaveBeenCalledWith(
|
|
[toolMocks.transformedTool],
|
|
expect.any(Function)
|
|
);
|
|
expect(result).toEqual('wrapped-tools-collection');
|
|
});
|
|
|
|
it('should get multiple tools and wrap them with provider', async () => {
|
|
const userId = 'test-user';
|
|
const filters = { tools: ['TOOL1', 'TOOL2'] };
|
|
|
|
const getRawComposioToolsSpy = vi.spyOn(context.tools, 'getRawComposioTools');
|
|
getRawComposioToolsSpy.mockResolvedValueOnce([toolMocks.transformedTool as unknown as Tool]);
|
|
|
|
context.mockProvider.wrapTools.mockReturnValueOnce('wrapped-tools-collection');
|
|
|
|
const result = await context.tools.get(userId, filters);
|
|
|
|
expect(getRawComposioToolsSpy).toHaveBeenCalledWith(
|
|
filters,
|
|
{ modifySchema: undefined },
|
|
undefined
|
|
);
|
|
expect(context.mockProvider.wrapTools).toHaveBeenCalled();
|
|
expect(result).toEqual('wrapped-tools-collection');
|
|
});
|
|
|
|
it('should pass modifiers to the underlying methods', async () => {
|
|
const userId = 'test-user';
|
|
const slug = 'TOOL_SLUG';
|
|
const schemaModifier = createSchemaModifier({
|
|
description: 'Modified description',
|
|
});
|
|
|
|
const getRawComposioToolBySlugSpy = vi.spyOn(context.tools, 'getRawComposioToolBySlug');
|
|
getRawComposioToolBySlugSpy.mockResolvedValueOnce(
|
|
toolMocks.transformedTool as unknown as Tool
|
|
);
|
|
|
|
await context.tools.get(userId, slug, { modifySchema: schemaModifier });
|
|
|
|
expect(getRawComposioToolBySlugSpy).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
modifySchema: schemaModifier,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('execute', () => {
|
|
it('should execute a composio tool', async () => {
|
|
const slug = 'COMPOSIO_TOOL';
|
|
const body = {
|
|
userId: 'test-user',
|
|
connectedAccountId: undefined,
|
|
arguments: { query: 'test' },
|
|
dangerouslySkipVersionCheck: true,
|
|
};
|
|
|
|
await mockToolExecution(context.tools);
|
|
|
|
const result = await context.tools.execute(slug, body);
|
|
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: undefined,
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: body.arguments,
|
|
user_id: body.userId,
|
|
version: 'latest',
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
expect(result).toEqual(toolMocks.toolExecuteResponse);
|
|
});
|
|
|
|
it('should execute a composio tool with a connected account', async () => {
|
|
const slug = 'COMPOSIO_TOOL';
|
|
const body = {
|
|
userId: 'test-user',
|
|
connectedAccountId: 'test-connected-account-id',
|
|
arguments: { query: 'test' },
|
|
dangerouslySkipVersionCheck: true,
|
|
};
|
|
|
|
await mockToolExecution(context.tools);
|
|
|
|
const result = await context.tools.execute(slug, body);
|
|
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: 'test-connected-account-id',
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: body.arguments,
|
|
user_id: body.userId,
|
|
version: 'latest',
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
expect(result).toEqual(toolMocks.toolExecuteResponse);
|
|
});
|
|
|
|
it('should transform customAuthParams.baseURL to base_url when executing a tool', async () => {
|
|
const slug = 'COMPOSIO_TOOL';
|
|
const body = {
|
|
userId: 'test-user',
|
|
connectedAccountId: 'test-connected-account-id',
|
|
arguments: { query: 'test' },
|
|
dangerouslySkipVersionCheck: true,
|
|
customAuthParams: {
|
|
baseURL: 'https://custom-api.example.com',
|
|
parameters: [{ name: 'x-api-key', value: 'test-key', in: 'header' as const }],
|
|
body: { extra: 'data' },
|
|
},
|
|
};
|
|
|
|
await mockToolExecution(context.tools);
|
|
|
|
const result = await context.tools.execute(slug, body);
|
|
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: 'test-connected-account-id',
|
|
custom_auth_params: {
|
|
base_url: 'https://custom-api.example.com',
|
|
parameters: [{ name: 'x-api-key', value: 'test-key', in: 'header' }],
|
|
body: { extra: 'data' },
|
|
},
|
|
custom_connection_data: undefined,
|
|
arguments: body.arguments,
|
|
user_id: body.userId,
|
|
version: 'latest',
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
expect(result).toEqual(toolMocks.toolExecuteResponse);
|
|
});
|
|
|
|
it('should pass version parameter from execute() to getRawComposioToolBySlug()', async () => {
|
|
const slug = 'COMPOSIO_TOOL';
|
|
const explicitVersion = '20250909_00';
|
|
const body = {
|
|
userId: 'test-user',
|
|
version: explicitVersion,
|
|
arguments: { title: 'Test Issue' },
|
|
};
|
|
|
|
const { getRawComposioToolBySlugSpy } = await mockToolExecution(context.tools);
|
|
|
|
await context.tools.execute(slug, body);
|
|
|
|
// Verify getRawComposioToolBySlug was called with version
|
|
expect(getRawComposioToolBySlugSpy).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
version: explicitVersion,
|
|
},
|
|
undefined
|
|
);
|
|
|
|
// Verify the API execute call received the version
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
slug,
|
|
expect.objectContaining({
|
|
version: explicitVersion,
|
|
}),
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should pass version: latest from execute() to getRawComposioToolBySlug()', async () => {
|
|
const slug = 'COMPOSIO_TOOL';
|
|
const body = {
|
|
userId: 'test-user',
|
|
version: 'latest' as const,
|
|
dangerouslySkipVersionCheck: true,
|
|
arguments: { title: 'Test Issue' },
|
|
};
|
|
|
|
const { getRawComposioToolBySlugSpy } = await mockToolExecution(context.tools);
|
|
|
|
await context.tools.execute(slug, body);
|
|
|
|
// Verify getRawComposioToolBySlug was called with version: 'latest'
|
|
expect(getRawComposioToolBySlugSpy).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
version: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should not pass version to getRawComposioToolBySlug() when not provided in execute()', async () => {
|
|
const slug = 'COMPOSIO_TOOL';
|
|
const body = {
|
|
userId: 'test-user',
|
|
arguments: { title: 'Test Issue' },
|
|
dangerouslySkipVersionCheck: true,
|
|
};
|
|
|
|
const { getRawComposioToolBySlugSpy } = await mockToolExecution(context.tools);
|
|
|
|
await context.tools.execute(slug, body);
|
|
|
|
// Verify getRawComposioToolBySlug was called without version (should fallback to SDK config)
|
|
expect(getRawComposioToolBySlugSpy).toHaveBeenCalledWith(
|
|
slug,
|
|
{
|
|
version: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('proxyExecute', () => {
|
|
it('should handle proxy request with headers and query parameters correctly', async () => {
|
|
const proxyParams = {
|
|
endpoint: '/api/test',
|
|
method: 'POST' as const,
|
|
body: { data: 'test' },
|
|
parameters: [
|
|
{
|
|
in: 'header' as const,
|
|
name: 'Authorization',
|
|
value: 'Bearer token123',
|
|
},
|
|
{
|
|
in: 'header' as const,
|
|
name: 'Content-Type',
|
|
value: 'application/json',
|
|
},
|
|
{
|
|
in: 'query' as const,
|
|
name: 'page',
|
|
value: 1,
|
|
},
|
|
{
|
|
in: 'query' as const,
|
|
name: 'limit',
|
|
value: 10,
|
|
},
|
|
],
|
|
connectedAccountId: 'test-account-id',
|
|
};
|
|
|
|
const expectedProxyResponse = {
|
|
data: { success: true },
|
|
successful: true,
|
|
};
|
|
|
|
mockClient.tools.proxy.mockResolvedValueOnce(expectedProxyResponse);
|
|
|
|
const result = await context.tools.proxyExecute(proxyParams);
|
|
|
|
expect(mockClient.tools.proxy).toHaveBeenCalledWith(
|
|
{
|
|
endpoint: '/api/test',
|
|
method: 'POST',
|
|
body: { data: 'test' },
|
|
connected_account_id: 'test-account-id',
|
|
parameters: [
|
|
{
|
|
name: 'Authorization',
|
|
type: 'header',
|
|
value: 'Bearer token123',
|
|
},
|
|
{
|
|
name: 'Content-Type',
|
|
type: 'header',
|
|
value: 'application/json',
|
|
},
|
|
{
|
|
name: 'page',
|
|
type: 'query',
|
|
value: '1',
|
|
},
|
|
{
|
|
name: 'limit',
|
|
type: 'query',
|
|
value: '10',
|
|
},
|
|
],
|
|
},
|
|
undefined
|
|
);
|
|
|
|
expect(result).toEqual(expectedProxyResponse);
|
|
});
|
|
|
|
it('should handle proxy request with only header parameters', async () => {
|
|
const proxyParams = {
|
|
endpoint: '/api/headers-only',
|
|
method: 'GET' as const,
|
|
parameters: [
|
|
{
|
|
in: 'header' as const,
|
|
name: 'Authorization',
|
|
value: 'Bearer token123',
|
|
},
|
|
{
|
|
in: 'header' as const,
|
|
name: 'Accept',
|
|
value: 'application/json',
|
|
},
|
|
],
|
|
};
|
|
|
|
const expectedProxyResponse = {
|
|
data: { result: 'success' },
|
|
successful: true,
|
|
};
|
|
|
|
mockClient.tools.proxy.mockResolvedValueOnce(expectedProxyResponse);
|
|
|
|
const result = await context.tools.proxyExecute(proxyParams);
|
|
|
|
expect(mockClient.tools.proxy).toHaveBeenCalledWith(
|
|
{
|
|
endpoint: '/api/headers-only',
|
|
method: 'GET',
|
|
body: undefined,
|
|
connected_account_id: undefined,
|
|
parameters: [
|
|
{
|
|
name: 'Authorization',
|
|
type: 'header',
|
|
value: 'Bearer token123',
|
|
},
|
|
{
|
|
name: 'Accept',
|
|
type: 'header',
|
|
value: 'application/json',
|
|
},
|
|
],
|
|
},
|
|
undefined
|
|
);
|
|
|
|
expect(result).toEqual(expectedProxyResponse);
|
|
});
|
|
|
|
it('should handle proxy request with only query parameters', async () => {
|
|
const proxyParams = {
|
|
endpoint: '/api/search',
|
|
method: 'GET' as const,
|
|
parameters: [
|
|
{
|
|
in: 'query' as const,
|
|
name: 'q',
|
|
value: 'test search',
|
|
},
|
|
{
|
|
in: 'query' as const,
|
|
name: 'page',
|
|
value: 2,
|
|
},
|
|
],
|
|
};
|
|
|
|
const expectedProxyResponse = {
|
|
data: { results: ['item1', 'item2'] },
|
|
successful: true,
|
|
};
|
|
|
|
mockClient.tools.proxy.mockResolvedValueOnce(expectedProxyResponse);
|
|
|
|
const result = await context.tools.proxyExecute(proxyParams);
|
|
|
|
expect(mockClient.tools.proxy).toHaveBeenCalledWith(
|
|
{
|
|
endpoint: '/api/search',
|
|
method: 'GET',
|
|
body: undefined,
|
|
connected_account_id: undefined,
|
|
parameters: [
|
|
{
|
|
name: 'q',
|
|
type: 'query',
|
|
value: 'test search',
|
|
},
|
|
{
|
|
name: 'page',
|
|
type: 'query',
|
|
value: '2',
|
|
},
|
|
],
|
|
},
|
|
undefined
|
|
);
|
|
|
|
expect(result).toEqual(expectedProxyResponse);
|
|
});
|
|
|
|
it('should handle proxy request without parameters', async () => {
|
|
const proxyParams = {
|
|
endpoint: '/api/no-params',
|
|
method: 'PUT' as const,
|
|
body: { update: 'data' },
|
|
connectedAccountId: 'test-account-id',
|
|
};
|
|
|
|
const expectedProxyResponse = {
|
|
data: { updated: true },
|
|
successful: true,
|
|
};
|
|
|
|
mockClient.tools.proxy.mockResolvedValueOnce(expectedProxyResponse);
|
|
|
|
const result = await context.tools.proxyExecute(proxyParams);
|
|
|
|
expect(mockClient.tools.proxy).toHaveBeenCalledWith(
|
|
{
|
|
endpoint: '/api/no-params',
|
|
method: 'PUT',
|
|
body: { update: 'data' },
|
|
connected_account_id: 'test-account-id',
|
|
parameters: [],
|
|
},
|
|
undefined
|
|
);
|
|
|
|
expect(result).toEqual(expectedProxyResponse);
|
|
});
|
|
|
|
it('should convert numeric parameter values to strings', async () => {
|
|
const proxyParams = {
|
|
endpoint: '/api/numeric',
|
|
method: 'GET' as const,
|
|
parameters: [
|
|
{
|
|
in: 'query' as const,
|
|
name: 'count',
|
|
value: 42,
|
|
},
|
|
{
|
|
in: 'header' as const,
|
|
name: 'Version',
|
|
value: 1.5,
|
|
},
|
|
],
|
|
};
|
|
|
|
const expectedProxyResponse = {
|
|
data: { result: 'ok' },
|
|
successful: true,
|
|
};
|
|
|
|
mockClient.tools.proxy.mockResolvedValueOnce(expectedProxyResponse);
|
|
|
|
const result = await context.tools.proxyExecute(proxyParams);
|
|
|
|
expect(mockClient.tools.proxy).toHaveBeenCalledWith(
|
|
{
|
|
endpoint: '/api/numeric',
|
|
method: 'GET',
|
|
body: undefined,
|
|
connected_account_id: undefined,
|
|
parameters: [
|
|
{
|
|
name: 'count',
|
|
type: 'query',
|
|
value: '42',
|
|
},
|
|
{
|
|
name: 'Version',
|
|
type: 'header',
|
|
value: '1.5',
|
|
},
|
|
],
|
|
},
|
|
undefined
|
|
);
|
|
|
|
expect(result).toEqual(expectedProxyResponse);
|
|
});
|
|
|
|
it('should throw validation error for invalid parameters', async () => {
|
|
const invalidProxyParams = {
|
|
endpoint: '/api/test',
|
|
method: 'INVALID_METHOD' as any,
|
|
parameters: [
|
|
{
|
|
in: 'header' as const,
|
|
name: 'Authorization',
|
|
value: 'Bearer token123',
|
|
},
|
|
],
|
|
};
|
|
|
|
await expect(context.tools.proxyExecute(invalidProxyParams)).rejects.toThrow(
|
|
'Invalid tool proxy parameters'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Tool Router Execution', () => {
|
|
const sessionId = 'test-session-123';
|
|
|
|
describe('executeSessionTool', () => {
|
|
it('should execute a tool via tool router session', async () => {
|
|
const toolSlug = 'COMPOSIO_TOOL';
|
|
const body = {
|
|
sessionId,
|
|
arguments: { query: 'test' },
|
|
};
|
|
|
|
mockClient.toolRouter.session.execute.mockResolvedValueOnce({
|
|
data: { results: true },
|
|
error: null,
|
|
log_id: '123',
|
|
});
|
|
|
|
const result = await context.tools.executeSessionTool(toolSlug, body);
|
|
|
|
expect(mockClient.toolRouter.session.execute).toHaveBeenCalledWith(
|
|
sessionId,
|
|
{
|
|
tool_slug: toolSlug,
|
|
arguments: body.arguments,
|
|
enable_auto_workbench_offload: true,
|
|
},
|
|
undefined
|
|
);
|
|
expect(result).toEqual({
|
|
data: { results: true },
|
|
error: null,
|
|
successful: true,
|
|
logId: '123',
|
|
});
|
|
});
|
|
|
|
it('should pass inline custom tools to tool router session execute', async () => {
|
|
const toolSlug = 'COMPOSIO_TOOL';
|
|
const body = {
|
|
sessionId,
|
|
arguments: { query: 'test' },
|
|
};
|
|
|
|
mockClient.toolRouter.session.execute.mockResolvedValueOnce({
|
|
data: { results: true },
|
|
error: null,
|
|
log_id: '123',
|
|
});
|
|
|
|
await context.tools.executeSessionTool(toolSlug, body, undefined, undefined, {
|
|
experimental: {
|
|
custom_tools: [
|
|
{
|
|
slug: 'GREP',
|
|
name: 'Grep',
|
|
description: 'Search local text',
|
|
input_schema: { type: 'object', properties: {} },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(mockClient.toolRouter.session.execute).toHaveBeenCalledWith(
|
|
sessionId,
|
|
{
|
|
tool_slug: toolSlug,
|
|
arguments: body.arguments,
|
|
enable_auto_workbench_offload: true,
|
|
experimental: {
|
|
custom_tools: [expect.objectContaining({ slug: 'GREP' })],
|
|
},
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should throw validation error for invalid parameters', async () => {
|
|
const invalidBody = {
|
|
// missing sessionId
|
|
arguments: { query: 'test' },
|
|
} as any;
|
|
|
|
await expect(
|
|
context.tools.executeSessionTool('COMPOSIO_TOOL', invalidBody)
|
|
).rejects.toThrow('Invalid tool execute session parameters');
|
|
});
|
|
|
|
it('should throw error if tool is not found', async () => {
|
|
const apiError = new Error('Tool not found');
|
|
mockClient.toolRouter.session.execute.mockRejectedValueOnce(apiError);
|
|
|
|
await expect(
|
|
context.tools.executeSessionTool('NONEXISTENT_TOOL', {
|
|
sessionId,
|
|
arguments: { query: 'test' },
|
|
})
|
|
).rejects.toThrow('Tool not found');
|
|
});
|
|
|
|
it('should apply beforeExecute modifier', async () => {
|
|
const toolSlug = 'COMPOSIO_TOOL';
|
|
const body = {
|
|
sessionId,
|
|
arguments: { query: 'original' },
|
|
};
|
|
|
|
const toolWithToolkit = {
|
|
...toolMocks.transformedTool,
|
|
toolkit: { slug: 'test-toolkit', name: 'Test Toolkit' },
|
|
};
|
|
|
|
mockClient.toolRouter.session.execute.mockResolvedValueOnce({
|
|
data: { results: true },
|
|
error: null,
|
|
log_id: '123',
|
|
});
|
|
|
|
const beforeExecute = vi.fn().mockImplementation(({ params }) => ({
|
|
...params,
|
|
query: 'modified',
|
|
}));
|
|
|
|
await context.tools.executeSessionTool(
|
|
toolSlug,
|
|
body,
|
|
{ beforeExecute },
|
|
toolWithToolkit as unknown as Tool
|
|
);
|
|
|
|
expect(beforeExecute).toHaveBeenCalledWith({
|
|
toolSlug,
|
|
toolkitSlug: 'test-toolkit',
|
|
sessionId,
|
|
params: { query: 'original' },
|
|
});
|
|
|
|
expect(mockClient.toolRouter.session.execute).toHaveBeenCalledWith(
|
|
sessionId,
|
|
{
|
|
tool_slug: toolSlug,
|
|
arguments: { query: 'modified' },
|
|
enable_auto_workbench_offload: true,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should apply afterExecute modifier', async () => {
|
|
const toolSlug = 'COMPOSIO_TOOL';
|
|
const body = {
|
|
sessionId,
|
|
arguments: { query: 'test' },
|
|
};
|
|
|
|
const toolWithToolkit = {
|
|
...toolMocks.transformedTool,
|
|
toolkit: { slug: 'test-toolkit', name: 'Test Toolkit' },
|
|
};
|
|
|
|
mockClient.toolRouter.session.execute.mockResolvedValueOnce({
|
|
data: { results: true },
|
|
error: null,
|
|
log_id: '123',
|
|
});
|
|
|
|
const afterExecute = vi.fn().mockImplementation(({ result }) => ({
|
|
...result,
|
|
data: { results: 'modified result' },
|
|
}));
|
|
|
|
const result = await context.tools.executeSessionTool(
|
|
toolSlug,
|
|
body,
|
|
{ afterExecute },
|
|
toolWithToolkit as unknown as Tool
|
|
);
|
|
|
|
expect(afterExecute).toHaveBeenCalledWith({
|
|
toolSlug,
|
|
toolkitSlug: 'test-toolkit',
|
|
sessionId,
|
|
result: {
|
|
data: { results: true },
|
|
error: null,
|
|
successful: true,
|
|
logId: '123',
|
|
},
|
|
});
|
|
|
|
expect(result.data).toEqual({ results: 'modified result' });
|
|
});
|
|
|
|
it('should handle error responses from tool router', async () => {
|
|
const toolSlug = 'COMPOSIO_TOOL';
|
|
const body = {
|
|
sessionId,
|
|
arguments: { query: 'test' },
|
|
};
|
|
|
|
mockClient.toolRouter.session.execute.mockResolvedValueOnce({
|
|
data: null,
|
|
error: 'Something went wrong',
|
|
log_id: '456',
|
|
});
|
|
|
|
const result = await context.tools.executeSessionTool(toolSlug, body);
|
|
|
|
expect(result).toEqual({
|
|
data: null,
|
|
error: 'Something went wrong',
|
|
successful: false,
|
|
logId: '456',
|
|
});
|
|
});
|
|
|
|
it('should handle tool without toolkit gracefully', async () => {
|
|
const toolSlug = 'TOOL_WITHOUT_TOOLKIT';
|
|
const body = {
|
|
sessionId,
|
|
arguments: { query: 'test' },
|
|
};
|
|
|
|
const toolWithoutToolkit = {
|
|
...toolMocks.transformedTool,
|
|
toolkit: undefined,
|
|
};
|
|
|
|
mockClient.toolRouter.session.execute.mockResolvedValueOnce({
|
|
data: { results: true },
|
|
error: null,
|
|
log_id: '123',
|
|
});
|
|
|
|
const beforeExecute = vi.fn().mockImplementation(({ params }) => params);
|
|
|
|
await context.tools.executeSessionTool(
|
|
toolSlug,
|
|
body,
|
|
{ beforeExecute },
|
|
toolWithoutToolkit as unknown as Tool
|
|
);
|
|
|
|
expect(beforeExecute).toHaveBeenCalledWith({
|
|
toolSlug,
|
|
toolkitSlug: 'composio',
|
|
sessionId,
|
|
params: { query: 'test' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('wrapToolsForToolRouter', () => {
|
|
it('should wrap tools with tool router execute function', async () => {
|
|
const tools = [toolMocks.transformedTool as unknown as Tool];
|
|
|
|
context.mockProvider.wrapTools.mockReturnValueOnce('wrapped-tools');
|
|
|
|
const result = context.tools.wrapToolsForToolRouter(sessionId, tools);
|
|
|
|
expect(context.mockProvider.wrapTools).toHaveBeenCalledWith(tools, expect.any(Function));
|
|
expect(result).toBe('wrapped-tools');
|
|
});
|
|
|
|
it('should create execute function that calls executeSessionTool', async () => {
|
|
const tools = [toolMocks.transformedTool as unknown as Tool];
|
|
|
|
let capturedExecuteFn: (toolSlug: string, input: Record<string, unknown>) => Promise<any>;
|
|
|
|
context.mockProvider.wrapTools.mockImplementation((tools, executeFn) => {
|
|
capturedExecuteFn = executeFn;
|
|
return 'wrapped-tools';
|
|
});
|
|
|
|
context.tools.wrapToolsForToolRouter(sessionId, tools);
|
|
|
|
mockClient.toolRouter.session.execute.mockResolvedValueOnce({
|
|
data: { results: true },
|
|
error: null,
|
|
log_id: '123',
|
|
});
|
|
|
|
// Call the captured execute function
|
|
const result = await capturedExecuteFn!('COMPOSIO_TOOL', { query: 'test' });
|
|
|
|
expect(mockClient.toolRouter.session.execute).toHaveBeenCalledWith(
|
|
sessionId,
|
|
{
|
|
tool_slug: 'COMPOSIO_TOOL',
|
|
arguments: { query: 'test' },
|
|
enable_auto_workbench_offload: true,
|
|
},
|
|
undefined
|
|
);
|
|
expect(result).toEqual({
|
|
data: { results: true },
|
|
error: null,
|
|
successful: true,
|
|
logId: '123',
|
|
});
|
|
});
|
|
|
|
it('should pass modifiers to the execute function', async () => {
|
|
const tools = [toolMocks.transformedTool as unknown as Tool];
|
|
const modifiers = {
|
|
beforeExecute: vi.fn().mockImplementation(({ params }) => ({
|
|
...params,
|
|
modified: true,
|
|
})),
|
|
};
|
|
|
|
let capturedExecuteFn: (toolSlug: string, input: Record<string, unknown>) => Promise<any>;
|
|
|
|
context.mockProvider.wrapTools.mockImplementation((tools, executeFn) => {
|
|
capturedExecuteFn = executeFn;
|
|
return 'wrapped-tools';
|
|
});
|
|
|
|
context.tools.wrapToolsForToolRouter(sessionId, tools, modifiers);
|
|
|
|
mockClient.toolRouter.session.execute.mockResolvedValueOnce({
|
|
data: { results: true },
|
|
error: null,
|
|
log_id: '123',
|
|
});
|
|
|
|
// Call the captured execute function
|
|
await capturedExecuteFn!('COMPOSIO_TOOL', { query: 'test' });
|
|
|
|
expect(modifiers.beforeExecute).toHaveBeenCalled();
|
|
expect(mockClient.toolRouter.session.execute).toHaveBeenCalledWith(
|
|
sessionId,
|
|
{
|
|
tool_slug: 'COMPOSIO_TOOL',
|
|
arguments: { query: 'test', modified: true },
|
|
enable_auto_workbench_offload: true,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle multiple tools', async () => {
|
|
const tools = [
|
|
toolMocks.transformedTool as unknown as Tool,
|
|
{ ...toolMocks.transformedTool, slug: 'ANOTHER_TOOL' } as unknown as Tool,
|
|
];
|
|
|
|
context.mockProvider.wrapTools.mockReturnValueOnce('wrapped-tools');
|
|
|
|
const result = context.tools.wrapToolsForToolRouter(sessionId, tools);
|
|
|
|
expect(context.mockProvider.wrapTools).toHaveBeenCalledWith(tools, expect.any(Function));
|
|
expect(result).toBe('wrapped-tools');
|
|
});
|
|
|
|
it('should handle empty tools array', async () => {
|
|
const tools: Tool[] = [];
|
|
|
|
context.mockProvider.wrapTools.mockReturnValueOnce([]);
|
|
|
|
const result = context.tools.wrapToolsForToolRouter(sessionId, tools);
|
|
|
|
expect(context.mockProvider.wrapTools).toHaveBeenCalledWith([], expect.any(Function));
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Version Integration Tests', () => {
|
|
describe('toolkit versions in tool fetching', () => {
|
|
it('should pass default "latest" version when no version is configured', async () => {
|
|
const context = createTestContext();
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await context.tools.getRawComposioTools({ tools: ['TEST_TOOL'] });
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
tool_slugs: 'TEST_TOOL',
|
|
limit: 9999,
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should pass global version string when configured', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: '20251201_03' as any,
|
|
});
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await tools.getRawComposioTools({ toolkits: ['github'] });
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
important: 'true',
|
|
toolkit_versions: '20251201_03',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should pass toolkit-specific versions when configured as object', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: {
|
|
github: '20251201_01',
|
|
slack: 'latest',
|
|
gmail: '20251201_05',
|
|
},
|
|
});
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await tools.getRawComposioTools({ toolkits: ['github'] });
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
important: 'true',
|
|
toolkit_versions: {
|
|
github: '20251201_01',
|
|
slack: 'latest',
|
|
gmail: '20251201_05',
|
|
},
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should pass versions when fetching tools by tool slugs', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: {
|
|
github: '20251201_01',
|
|
slack: 'latest',
|
|
},
|
|
});
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await tools.getRawComposioTools({ tools: ['GITHUB_CREATE_ISSUE', 'SLACK_SEND_MESSAGE'] });
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
tool_slugs: 'GITHUB_CREATE_ISSUE,SLACK_SEND_MESSAGE',
|
|
limit: 9999,
|
|
toolkit_versions: {
|
|
github: '20251201_01',
|
|
slack: 'latest',
|
|
},
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should pass versions when searching tools', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: 'latest',
|
|
});
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await tools.getRawComposioTools({ search: 'create issue' });
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
search: 'create issue',
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('toolkit versions in individual tool retrieval', () => {
|
|
it('should pass default "latest" version when retrieving single tool', async () => {
|
|
const context = createTestContext();
|
|
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.rawTool);
|
|
|
|
await context.tools.getRawComposioToolBySlug('GITHUB_CREATE_ISSUE');
|
|
|
|
expect(mockClient.tools.retrieve).toHaveBeenCalledWith(
|
|
'GITHUB_CREATE_ISSUE',
|
|
{
|
|
toolkit_versions: 'latest',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should pass global version when retrieving single tool', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: '20251201_03' as any,
|
|
});
|
|
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.rawTool);
|
|
|
|
await tools.getRawComposioToolBySlug('GITHUB_CREATE_ISSUE');
|
|
|
|
expect(mockClient.tools.retrieve).toHaveBeenCalledWith(
|
|
'GITHUB_CREATE_ISSUE',
|
|
{
|
|
toolkit_versions: '20251201_03',
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should pass toolkit-specific versions when retrieving single tool', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: {
|
|
github: '20251201_01',
|
|
slack: 'latest',
|
|
gmail: '20251201_05',
|
|
},
|
|
});
|
|
|
|
mockClient.tools.retrieve.mockResolvedValueOnce(toolMocks.rawTool);
|
|
|
|
await tools.getRawComposioToolBySlug('SLACK_SEND_MESSAGE');
|
|
|
|
expect(mockClient.tools.retrieve).toHaveBeenCalledWith(
|
|
'SLACK_SEND_MESSAGE',
|
|
{
|
|
toolkit_versions: {
|
|
github: '20251201_01',
|
|
slack: 'latest',
|
|
gmail: '20251201_05',
|
|
},
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('toolkit versions in tool execution', () => {
|
|
it('should use default "latest" version when executing tool without explicit version', async () => {
|
|
const context = createTestContext();
|
|
const spies = await mockToolExecution(context.tools);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Issue' },
|
|
connectedAccountId: 'test-account',
|
|
dangerouslySkipVersionCheck: true, // Required when using 'latest' in manual execution
|
|
};
|
|
|
|
await context.tools.execute('GITHUB_CREATE_ISSUE', executeParams);
|
|
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
'COMPOSIO_TOOL',
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: 'test-account',
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: { title: 'Test Issue' },
|
|
user_id: undefined,
|
|
version: 'latest', // should use latest as default
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should use global version when configured', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: '20251201_03' as any,
|
|
});
|
|
const spies = await mockToolExecution(tools);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Issue' },
|
|
connectedAccountId: 'test-account',
|
|
};
|
|
|
|
await tools.execute('GITHUB_CREATE_ISSUE', executeParams);
|
|
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
'COMPOSIO_TOOL',
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: 'test-account',
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: { title: 'Test Issue' },
|
|
user_id: undefined,
|
|
version: '20251201_03', // should use global version
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should use toolkit-specific version when configured as object', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: {
|
|
'test-toolkit': '20251201_01', // Use the actual toolkit slug from mock
|
|
slack: 'latest',
|
|
gmail: '20251201_05',
|
|
},
|
|
});
|
|
|
|
// Mock the tool with test-toolkit (matching the mock data)
|
|
const testTool = {
|
|
...toolMocks.transformedTool,
|
|
toolkit: { slug: 'test-toolkit', name: 'Test Toolkit' }, // Use actual mock toolkit
|
|
};
|
|
|
|
const spies = await mockToolExecution(tools);
|
|
// Override the mock to return our custom tool with the correct toolkit
|
|
spies.getRawComposioToolBySlugSpy.mockReset();
|
|
spies.getRawComposioToolBySlugSpy.mockResolvedValueOnce(testTool as unknown as Tool);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Issue' },
|
|
connectedAccountId: 'test-account',
|
|
};
|
|
|
|
await tools.execute('GITHUB_CREATE_ISSUE', executeParams);
|
|
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
'COMPOSIO_TOOL',
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: 'test-account',
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: { title: 'Test Issue' },
|
|
user_id: undefined,
|
|
version: '20251201_01', // should use test-toolkit-specific version
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should use fallback to "latest" when toolkit not in version mapping', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: {
|
|
github: '20251201_01',
|
|
slack: 'latest',
|
|
},
|
|
});
|
|
|
|
// Mock the tool with notion toolkit (not in version mapping)
|
|
const notionTool = {
|
|
...toolMocks.transformedTool,
|
|
toolkit: { slug: 'notion', name: 'Notion' },
|
|
};
|
|
|
|
const spies = await mockToolExecution(tools);
|
|
spies.getRawComposioToolBySlugSpy.mockResolvedValueOnce(notionTool as unknown as Tool);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Page' },
|
|
connectedAccountId: 'test-account',
|
|
dangerouslySkipVersionCheck: true, // Required when fallback is 'latest'
|
|
};
|
|
|
|
await tools.execute('NOTION_CREATE_PAGE', executeParams);
|
|
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
'COMPOSIO_TOOL',
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: 'test-account',
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: { title: 'Test Page' },
|
|
user_id: undefined,
|
|
version: 'latest', // should fallback to latest for unknown toolkit
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should prioritize explicit version parameter over configured versions', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: {
|
|
github: '20251201_01',
|
|
slack: 'latest',
|
|
},
|
|
});
|
|
|
|
const spies = await mockToolExecution(tools);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Issue' },
|
|
connectedAccountId: 'test-account',
|
|
version: '20251201_03', // explicit version should override config
|
|
};
|
|
|
|
await tools.execute('GITHUB_CREATE_ISSUE', executeParams);
|
|
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
'COMPOSIO_TOOL',
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: 'test-account',
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: { title: 'Test Issue' },
|
|
user_id: undefined,
|
|
version: '20251201_03', // explicit version takes precedence
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should handle tool without toolkit gracefully', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: {
|
|
github: '20251201_01',
|
|
},
|
|
});
|
|
|
|
// Mock the tool without toolkit
|
|
const toolWithoutToolkit = {
|
|
...toolMocks.transformedTool,
|
|
toolkit: undefined,
|
|
};
|
|
|
|
const spies = await mockToolExecution(tools);
|
|
spies.getRawComposioToolBySlugSpy.mockResolvedValueOnce(
|
|
toolWithoutToolkit as unknown as Tool
|
|
);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { query: 'test' },
|
|
connectedAccountId: 'test-account',
|
|
dangerouslySkipVersionCheck: true, // Required when toolkit is undefined and version is 'latest'
|
|
};
|
|
|
|
await tools.execute('SOME_CUSTOM_TOOL', executeParams);
|
|
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
'COMPOSIO_TOOL',
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: 'test-account',
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: { query: 'test' },
|
|
user_id: undefined,
|
|
version: 'latest', // should fallback to latest for unknown toolkit
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('environment variable integration', () => {
|
|
const originalEnv = process.env;
|
|
|
|
beforeEach(() => {
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
});
|
|
|
|
it('should use environment variable versions when no user config provided', async () => {
|
|
// Set environment variables
|
|
process.env.COMPOSIO_TOOLKIT_VERSION_GITHUB = '20251201_08';
|
|
process.env.COMPOSIO_TOOLKIT_VERSION_SLACK = 'latest';
|
|
|
|
const mockProvider = new MockProvider();
|
|
// Pass the processed environment variables to the Tools constructor
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: {
|
|
github: '20251201_08',
|
|
slack: 'latest',
|
|
},
|
|
});
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await tools.getRawComposioTools({ toolkits: ['github'] });
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
important: 'true',
|
|
toolkit_versions: {
|
|
github: '20251201_08',
|
|
slack: 'latest',
|
|
},
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should prioritize user config over environment variables', async () => {
|
|
// Set environment variables
|
|
process.env.COMPOSIO_TOOLKIT_VERSION_GITHUB = '20251201_08';
|
|
process.env.COMPOSIO_TOOLKIT_VERSION_SLACK = 'latest';
|
|
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: {
|
|
github: '20251201_04', // should override env
|
|
gmail: '20251201_05', // new toolkit not in env
|
|
},
|
|
});
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await tools.getRawComposioTools({ toolkits: ['github'] });
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
important: 'true',
|
|
toolkit_versions: {
|
|
github: '20251201_04', // user config wins
|
|
gmail: '20251201_05', // from user config
|
|
},
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should use global version string to override everything', async () => {
|
|
// Set environment variables
|
|
process.env.COMPOSIO_TOOLKIT_VERSION_GITHUB = '20251201_08';
|
|
process.env.COMPOSIO_TOOLKIT_VERSION_SLACK = 'latest';
|
|
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: '20251201_09' as any, // global version overrides everything
|
|
});
|
|
|
|
mockClient.tools.list.mockResolvedValueOnce({
|
|
items: [toolMocks.rawTool],
|
|
totalPages: 1,
|
|
});
|
|
|
|
await tools.getRawComposioTools({ toolkits: ['github'] });
|
|
|
|
expect(mockClient.tools.list).toHaveBeenCalledWith(
|
|
{
|
|
toolkit_slug: 'github',
|
|
important: 'true',
|
|
toolkit_versions: '20251201_09', // global version ignores env vars
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('version check safety for manual execution', () => {
|
|
it('should throw ComposioToolVersionRequiredError when version is "latest" without dangerouslySkipVersionCheck', async () => {
|
|
const context = createTestContext();
|
|
const spies = await mockToolExecution(context.tools);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Issue' },
|
|
connectedAccountId: 'test-account',
|
|
// dangerouslySkipVersionCheck is not set, should default to false
|
|
};
|
|
|
|
await expect(context.tools.execute('GITHUB_CREATE_ISSUE', executeParams)).rejects.toThrow(
|
|
'Toolkit version not specified. For manual execution of the tool please pass a specific toolkit version'
|
|
);
|
|
});
|
|
|
|
it('should throw ComposioToolVersionRequiredError when version is "latest" with dangerouslySkipVersionCheck set to false', async () => {
|
|
const context = createTestContext();
|
|
const spies = await mockToolExecution(context.tools);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Issue' },
|
|
connectedAccountId: 'test-account',
|
|
dangerouslySkipVersionCheck: false,
|
|
};
|
|
|
|
await expect(context.tools.execute('GITHUB_CREATE_ISSUE', executeParams)).rejects.toThrow(
|
|
'Toolkit version not specified. For manual execution of the tool please pass a specific toolkit version'
|
|
);
|
|
});
|
|
|
|
it('should succeed when version is "latest" with dangerouslySkipVersionCheck set to true', async () => {
|
|
const context = createTestContext();
|
|
const spies = await mockToolExecution(context.tools);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Issue' },
|
|
connectedAccountId: 'test-account',
|
|
dangerouslySkipVersionCheck: true,
|
|
};
|
|
|
|
const result = await context.tools.execute('GITHUB_CREATE_ISSUE', executeParams);
|
|
|
|
expect(result).toEqual(toolMocks.toolExecuteResponse);
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
'COMPOSIO_TOOL',
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: 'test-account',
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: { title: 'Test Issue' },
|
|
user_id: undefined,
|
|
version: 'latest',
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should succeed when executing with a specific version (not "latest") without dangerouslySkipVersionCheck', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: {
|
|
'test-toolkit': '20251201_01',
|
|
},
|
|
});
|
|
|
|
const testTool = {
|
|
...toolMocks.transformedTool,
|
|
toolkit: { slug: 'test-toolkit', name: 'Test Toolkit' },
|
|
};
|
|
|
|
const spies = await mockToolExecution(tools);
|
|
spies.getRawComposioToolBySlugSpy.mockReset();
|
|
spies.getRawComposioToolBySlugSpy.mockResolvedValueOnce(testTool as unknown as Tool);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Issue' },
|
|
connectedAccountId: 'test-account',
|
|
// No dangerouslySkipVersionCheck needed when version is specific
|
|
};
|
|
|
|
const result = await tools.execute('GITHUB_CREATE_ISSUE', executeParams);
|
|
|
|
expect(result).toEqual(toolMocks.toolExecuteResponse);
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
'COMPOSIO_TOOL',
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: 'test-account',
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: { title: 'Test Issue' },
|
|
user_id: undefined,
|
|
version: '20251201_01', // specific version should work without skip flag
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should succeed when explicit version parameter overrides "latest" config', async () => {
|
|
const context = createTestContext();
|
|
const spies = await mockToolExecution(context.tools);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Issue' },
|
|
connectedAccountId: 'test-account',
|
|
version: '20251201_03', // explicit version overrides default "latest"
|
|
// No dangerouslySkipVersionCheck needed
|
|
};
|
|
|
|
const result = await context.tools.execute('GITHUB_CREATE_ISSUE', executeParams);
|
|
|
|
expect(result).toEqual(toolMocks.toolExecuteResponse);
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
'COMPOSIO_TOOL',
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: 'test-account',
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: { title: 'Test Issue' },
|
|
user_id: undefined,
|
|
version: '20251201_03',
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it('should throw error when explicit version parameter is "latest" without dangerouslySkipVersionCheck', async () => {
|
|
const mockProvider = new MockProvider();
|
|
const tools = new Tools(mockClient as unknown as ComposioClient, {
|
|
provider: mockProvider,
|
|
toolkitVersions: {
|
|
'test-toolkit': '20251201_01', // specific version in config
|
|
},
|
|
});
|
|
|
|
const testTool = {
|
|
...toolMocks.transformedTool,
|
|
toolkit: { slug: 'test-toolkit', name: 'Test Toolkit' },
|
|
};
|
|
|
|
const spies = await mockToolExecution(tools);
|
|
spies.getRawComposioToolBySlugSpy.mockReset();
|
|
spies.getRawComposioToolBySlugSpy.mockResolvedValueOnce(testTool as unknown as Tool);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Issue' },
|
|
connectedAccountId: 'test-account',
|
|
version: 'latest', // explicit "latest" should still require skip flag
|
|
};
|
|
|
|
await expect(tools.execute('GITHUB_CREATE_ISSUE', executeParams)).rejects.toThrow(
|
|
'Toolkit version not specified. For manual execution of the tool please pass a specific toolkit version'
|
|
);
|
|
});
|
|
|
|
it('should contain appropriate error properties and possible fixes in the cause', async () => {
|
|
const context = createTestContext();
|
|
const spies = await mockToolExecution(context.tools);
|
|
|
|
const executeParams: ToolExecuteParams = {
|
|
arguments: { title: 'Test Issue' },
|
|
connectedAccountId: 'test-account',
|
|
};
|
|
|
|
try {
|
|
await context.tools.execute('GITHUB_CREATE_ISSUE', executeParams);
|
|
expect.fail('Should have thrown an error');
|
|
} catch (error: any) {
|
|
// The error should be wrapped in ComposioToolExecutionError
|
|
expect(error).toBeDefined();
|
|
|
|
// Check if the error has the right properties, whether wrapped or not
|
|
if (error.cause) {
|
|
// If wrapped, check the cause
|
|
expect(error.cause.code).toContain('TOOL_VERSION_REQUIRED');
|
|
expect(error.cause.message).toContain('Toolkit version not specified');
|
|
expect(error.cause.possibleFixes).toBeDefined();
|
|
expect(error.cause.possibleFixes).toContain(
|
|
'Pass the toolkit version as a parameter to the execute function ("latest" is not supported in manual execution)'
|
|
);
|
|
} else {
|
|
// If not wrapped (direct error), check the error itself
|
|
expect(error.code).toContain('TOOL_VERSION_REQUIRED');
|
|
expect(error.message).toContain('Toolkit version not specified');
|
|
expect(error.possibleFixes).toBeDefined();
|
|
expect(error.possibleFixes).toContain(
|
|
'Pass the toolkit version as a parameter to the execute function ("latest" is not supported in manual execution)'
|
|
);
|
|
expect(error.possibleFixes).toContain(
|
|
'Set the toolkit versions in the Composio config (toolkitVersions: { <toolkit-slug>: "<toolkit-version>" })'
|
|
);
|
|
expect(error.possibleFixes).toContain(
|
|
'Set the toolkit version in the environment variable (COMPOSIO_TOOLKIT_VERSION_<TOOLKIT_SLUG>)'
|
|
);
|
|
expect(error.possibleFixes).toContain(
|
|
'Set dangerouslySkipVersionCheck to true (this might cause unexpected behavior when new versions of the tools are released)'
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should allow agentic provider execution with dangerouslySkipVersionCheck in createExecuteToolFn', async () => {
|
|
const context = createTestContext();
|
|
const userId = 'test-user';
|
|
|
|
// Mock tool retrieval for the get method
|
|
const getRawComposioToolBySlugSpy = vi.spyOn(context.tools, 'getRawComposioToolBySlug');
|
|
getRawComposioToolBySlugSpy.mockResolvedValueOnce(
|
|
toolMocks.transformedTool as unknown as Tool
|
|
);
|
|
|
|
// Mock provider wrapping
|
|
context.mockProvider.wrapTools.mockImplementation((tools, executeToolFn) => {
|
|
// Store the execute function so we can test it
|
|
(context as any).storedExecuteToolFn = executeToolFn;
|
|
return 'wrapped-tools-collection';
|
|
});
|
|
|
|
// Get the tool (this will internally create the execute tool function)
|
|
await context.tools.get(userId, 'GITHUB_CREATE_ISSUE');
|
|
|
|
// Now call the stored execute function (simulating agentic provider calling it)
|
|
const storedExecuteToolFn = (context as any).storedExecuteToolFn;
|
|
expect(storedExecuteToolFn).toBeDefined();
|
|
|
|
// Setup mocks for the actual execution
|
|
const spies = await mockToolExecution(context.tools);
|
|
|
|
// Call the execute function that was passed to the provider
|
|
// This should succeed because createExecuteToolFn sets dangerouslySkipVersionCheck: true
|
|
const result = await storedExecuteToolFn('GITHUB_CREATE_ISSUE', { title: 'Test Issue' });
|
|
|
|
expect(result).toEqual(toolMocks.toolExecuteResponse);
|
|
expect(mockClient.tools.execute).toHaveBeenCalledWith(
|
|
'COMPOSIO_TOOL',
|
|
{
|
|
allow_tracing: undefined,
|
|
connected_account_id: undefined,
|
|
custom_auth_params: undefined,
|
|
custom_connection_data: undefined,
|
|
arguments: { title: 'Test Issue' },
|
|
user_id: userId,
|
|
version: 'latest',
|
|
text: undefined,
|
|
},
|
|
undefined
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('retries disabled on non-idempotent writes', () => {
|
|
// Regression: a timed-out, non-idempotent tools.execute / tools.proxy must not
|
|
// be silently retried — a retry after a server-side success duplicates the side
|
|
// effect (e.g. sends the same email up to 3 times). Both route through a sibling
|
|
// client built with maxRetries: 0; reads keep the client's default retries.
|
|
// See https://github.com/ComposioHQ/composio/issues/3586 (TS parity with Python).
|
|
const context = createTestContext();
|
|
setupTest(context);
|
|
|
|
it('routes tools.execute through a client with maxRetries: 0', async () => {
|
|
await mockToolExecution(context.tools);
|
|
|
|
await context.tools.execute('COMPOSIO_TOOL', {
|
|
userId: 'test-user',
|
|
arguments: { query: 'test' },
|
|
dangerouslySkipVersionCheck: true,
|
|
});
|
|
|
|
expect(mockClient.withOptions).toHaveBeenCalledWith({ maxRetries: 0 });
|
|
expect(mockClient.tools.execute).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('routes tools.proxyExecute through a client with maxRetries: 0', async () => {
|
|
mockClient.tools.proxy.mockResolvedValueOnce({ data: {}, successful: true });
|
|
|
|
await context.tools.proxyExecute({
|
|
endpoint: '/api/test',
|
|
method: 'POST' as const,
|
|
body: { data: 'test' },
|
|
connectedAccountId: 'test-account-id',
|
|
});
|
|
|
|
expect(mockClient.withOptions).toHaveBeenCalledWith({ maxRetries: 0 });
|
|
expect(mockClient.tools.proxy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('reuses one no-retries sibling client across executes (cached per instance)', async () => {
|
|
const { getRawComposioToolBySlugSpy } = await mockToolExecution(context.tools);
|
|
|
|
await context.tools.execute('COMPOSIO_TOOL', {
|
|
userId: 'test-user',
|
|
arguments: { query: 'test' },
|
|
dangerouslySkipVersionCheck: true,
|
|
});
|
|
|
|
// Queue a second execute; mockToolExecution only primed one response.
|
|
getRawComposioToolBySlugSpy.mockResolvedValueOnce(toolMocks.transformedTool as unknown as Tool);
|
|
mockClient.tools.execute.mockResolvedValueOnce(toolMocks.rawToolExecuteResponse);
|
|
await context.tools.execute('COMPOSIO_TOOL', {
|
|
userId: 'test-user',
|
|
arguments: { query: 'test again' },
|
|
dangerouslySkipVersionCheck: true,
|
|
});
|
|
|
|
expect(mockClient.tools.execute).toHaveBeenCalledTimes(2);
|
|
expect(mockClient.withOptions).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|