Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion examples/ai-sdk-integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
/**
* E2E test for ai-sdk-integration.ts example
*
* Tests the complete flow of using StackOne tools with the AI SDK.
* Tests the complete flow of using StackOne tools with the AI SDK,
* including both fetchTools and semantic search paths.
*/

import { openai } from '@ai-sdk/openai';
import { generateText, stepCountIs } from 'ai';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/node';
import { StackOneToolSet } from '../src';

describe('ai-sdk-integration example e2e', () => {
Expand Down Expand Up @@ -49,4 +52,63 @@ describe('ai-sdk-integration example e2e', () => {
// The mocked OpenAI response includes 'Michael' in the text
expect(text).toContain('Michael');
});

it('should discover tools via semantic search, convert to AI SDK format, and generate text', async () => {
// Mock semantic search to return bamboohr results
server.use(
http.post('https://api.stackone.com/actions/search', async ({ request }) => {
const body = (await request.json()) as { query: string };
return HttpResponse.json({
results: [
{
action_name: 'bamboohr_1.0.0_bamboohr_get_employee_global',
connector_key: 'bamboohr',
similarity_score: 0.95,
label: 'Get Employee',
description: 'Get employee details from BambooHR',
},
{
action_name: 'bamboohr_1.0.0_bamboohr_list_employees_global',
connector_key: 'bamboohr',
similarity_score: 0.88,
label: 'List Employees',
description: 'List all employees from BambooHR',
},
],
total_count: 2,
query: body.query,
});
}),
);

const toolset = new StackOneToolSet({
accountId: 'your-bamboohr-account-id',
baseUrl: 'https://api.stackone.com',
});

// Discover tools via semantic search
const tools = await toolset.searchTools('get employee details', { topK: 5 });
expect(tools.length).toBeGreaterThan(0);

// Convert to AI SDK tools
const aiSdkTools = await tools.toAISDK();
expect(Object.keys(aiSdkTools).length).toBeGreaterThan(0);
expect(aiSdkTools).toHaveProperty('bamboohr_get_employee');

// Each tool should have execute function
for (const name of Object.keys(aiSdkTools)) {
expect(typeof aiSdkTools[name].execute).toBe('function');
expect(aiSdkTools[name].inputSchema).toBeDefined();
}

// Use with generateText
const { text } = await generateText({
model: openai('gpt-5'),
tools: aiSdkTools,
prompt: 'Get all details about employee with id: c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA',
stopWhen: stepCountIs(3),
});

expect(text).toContain('Michael');
});
});
29 changes: 29 additions & 0 deletions examples/ai-sdk-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,33 @@ const aiSdkIntegration = async (): Promise<void> => {
assert(text.includes('Michael'), 'Expected employee name to be included in the response');
};

/**
* Semantic search variant: discover tools via natural language, then use
* them with the AI SDK. This is the recommended approach when you don't
* know the exact tool names upfront.
*/
const aiSdkSemanticSearchIntegration = async (): Promise<void> => {
const toolset = new StackOneToolSet({
accountId,
baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com',
});

// Discover relevant tools using natural language
const tools = await toolset.searchTools('get employee details', { topK: 5 });

// Convert to AI SDK tools
const aiSdkTools = await tools.toAISDK();

// Use with generateText — the AI SDK handles tool calls automatically
const { text } = await generateText({
model: openai('gpt-5.1'),
tools: aiSdkTools,
prompt: 'Get all details about employee with id: c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA',
stopWhen: stepCountIs(3),
});

assert(text.includes('Michael'), 'Expected employee name to be included in the response');
};

await aiSdkIntegration();
await aiSdkSemanticSearchIntegration();
248 changes: 248 additions & 0 deletions examples/semantic-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/**
* E2E test for semantic-search.ts example
*
* Tests the complete flow of semantic search using Calendly-themed tools:
* searchActionNames, searchTools, searchTools with connector, and utility
* tools with semantic search.
*/

import { http, HttpResponse } from 'msw';
import { server } from '../mocks/node';
import { type SemanticSearchResult, StackOneToolSet } from '../src';

/** Helper to create a semantic search result */
function semanticResult(
actionName: string,
connectorKey: string,
similarityScore: number,
description = `${actionName} description`,
): Record<string, unknown> {
return {
action_name: actionName,
connector_key: connectorKey,
similarity_score: similarityScore,
label: actionName.replace(/_/g, ' '),
description,
};
}

/** Set up a mock handler for the semantic search API */
function mockSemanticSearch(results: Record<string, unknown>[]): void {
server.use(
http.post('https://api.stackone.com/actions/search', async ({ request }) => {
const body = (await request.json()) as { query: string; connector?: string };
const filtered = body.connector
? results.filter((r) => r.connector_key === body.connector)
: results;
return HttpResponse.json({
results: filtered,
total_count: filtered.length,
query: body.query,
});
}),
);
}

describe('semantic-search example e2e', () => {
const semanticResults = [
semanticResult(
'calendly_1.0.0_calendly_list_events_global',
'calendly',
0.95,
'List scheduled events from Calendly',
),
semanticResult(
'calendly_1.0.0_calendly_cancel_event_global',
'calendly',
0.88,
'Cancel a scheduled event in Calendly',
),
semanticResult(
'calendly_1.0.0_calendly_create_scheduling_link_global',
'calendly',
0.82,
'Create a new scheduling link in Calendly',
),
semanticResult(
'calendly_1.0.0_calendly_get_event_global',
'calendly',
0.75,
'Get details of a specific event from Calendly',
),
];

beforeEach(() => {
vi.stubEnv('STACKONE_API_KEY', 'test-key');
});

afterEach(() => {
vi.unstubAllEnvs();
});

it('searchActionNames returns action names and scores without fetching tools', async () => {
mockSemanticSearch(semanticResults);

const toolset = new StackOneToolSet({
baseUrl: 'https://api.stackone.com',
});

const results = await toolset.searchActionNames('get user schedule', { topK: 5 });

expect(results.length).toBeGreaterThan(0);
for (const r of results) {
expect(r.actionName).toBeDefined();
expect(r.connectorKey).toBeDefined();
expect(r.similarityScore).toBeGreaterThan(0);
}
});

it('searchActionNames filters to connectors available in linked accounts', async () => {
mockSemanticSearch(semanticResults);

const toolset = new StackOneToolSet({
baseUrl: 'https://api.stackone.com',
});

// With accountIds, results are filtered to connectors in those accounts
const results = await toolset.searchActionNames('cancel an event', {
accountIds: ['your-calendly-account-id'],
topK: 5,
});

expect(results.length).toBeGreaterThan(0);
// All results should be for calendly since that's the only connector in the account
for (const r of results) {
expect(r.connectorKey).toBe('calendly');
}
});

it('searchTools returns a Tools collection with matched tools', async () => {
mockSemanticSearch(semanticResults);

const toolset = new StackOneToolSet({
baseUrl: 'https://api.stackone.com',
});

const tools = await toolset.searchTools('cancel an event', {
accountIds: ['your-calendly-account-id'],
topK: 5,
});

expect(tools.length).toBeGreaterThan(0);

// Tools should be convertible to OpenAI format
const openaiTools = tools.toOpenAI();
expect(openaiTools.length).toBeGreaterThan(0);
expect(openaiTools[0]).toHaveProperty('type', 'function');

// All matched tools should be calendly tools
for (const tool of tools.toArray()) {
expect(tool.name).toMatch(/^calendly_/);
}
});

it('searchTools with connector filter scopes to a specific provider', async () => {
mockSemanticSearch(semanticResults);

const toolset = new StackOneToolSet({
baseUrl: 'https://api.stackone.com',
});

const tools = await toolset.searchTools('book a meeting', {
connector: 'calendly',
accountIds: ['your-calendly-account-id'],
topK: 3,
});

expect(tools.length).toBeGreaterThan(0);
for (const tool of tools.toArray()) {
expect(tool.name.startsWith('calendly_')).toBe(true);
}
});

it('utility tools with semantic search creates semantic tool_search', async () => {
mockSemanticSearch(semanticResults);

const toolset = new StackOneToolSet({
baseUrl: 'https://api.stackone.com',
});

const allTools = await toolset.fetchTools({
accountIds: ['your-calendly-account-id'],
});

const utility = await allTools.utilityTools({
semanticClient: toolset.semanticClient,
});

// Should have tool_search and tool_execute
const searchTool = utility.getTool('tool_search');
const executeTool = utility.getTool('tool_execute');
expect(searchTool).toBeDefined();
expect(executeTool).toBeDefined();

// tool_search with semantic should have a connector parameter
const schema = searchTool!.parameters;
expect(schema.properties).toHaveProperty('connector');

// Execute semantic tool_search
const result = await searchTool!.execute({ query: 'cancel an event or meeting', limit: 5 });
const tools = (result as { tools?: Array<{ name: string; score: number }> }).tools;
expect(tools).toBeDefined();
expect(tools!.length).toBeGreaterThan(0);
});

it('searchTools converts to AI SDK format with correct structure', async () => {
mockSemanticSearch(semanticResults);

const toolset = new StackOneToolSet({
baseUrl: 'https://api.stackone.com',
});

const tools = await toolset.searchTools('cancel an event', {
accountIds: ['your-calendly-account-id'],
topK: 5,
});

// Convert to AI SDK format
const aiSdkTools = await tools.toAISDK();

// Should contain calendly tools as keys
const toolNames = Object.keys(aiSdkTools);
expect(toolNames.length).toBeGreaterThan(0);
for (const name of toolNames) {
expect(name).toMatch(/^calendly_/);
}

// Each tool should have the expected AI SDK structure
for (const name of toolNames) {
const tool = aiSdkTools[name];
expect(tool.inputSchema).toBeDefined();
expect(tool.description).toBeDefined();
expect(typeof tool.execute).toBe('function');
}
});

it('searchActionNames normalizes and deduplicates action names', async () => {
// Provide multiple API versions of the same action
const dupeResults = [
semanticResult('calendly_1.0.0_calendly_list_events_global', 'calendly', 0.95),
semanticResult('calendly_2.0.0_calendly_list_events_global', 'calendly', 0.9),
semanticResult('calendly_1.0.0_calendly_cancel_event_global', 'calendly', 0.85),
];
mockSemanticSearch(dupeResults);

const toolset = new StackOneToolSet({
baseUrl: 'https://api.stackone.com',
});

const results = await toolset.searchActionNames('events', { topK: 10 });

// Should be deduplicated — only one "calendly_list_events" and one "calendly_cancel_event"
const actionNames = results.map((r: SemanticSearchResult) => r.actionName);
const uniqueNames = new Set(actionNames);
expect(uniqueNames.size).toBe(actionNames.length);
expect(actionNames).toContain('calendly_list_events');
expect(actionNames).toContain('calendly_cancel_event');
});
});
Loading
Loading