diff --git a/README.md b/README.md index 2378b1c7..46df7cc6 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ > A unified interface for performing actions on SaaS tools through AI-friendly APIs. [![DeepWiki](https://img.shields.io/badge/DeepWiki-StackOneHQ%2Fstackone--ai--node-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/StackOneHQ/stackone-ai-node) + ## Toolsets @@ -146,11 +147,11 @@ Call `fetchTools()` when you want the SDK to pull the current tool definitions d ```typescript const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', + baseUrl: "https://api.stackone.com", }); const tools = await toolset.fetchTools(); -const employeeTool = tools.getTool('hris_list_employees'); +const employeeTool = tools.getTool("hris_list_employees"); const result = await employeeTool?.execute({ query: { limit: 5 }, @@ -165,31 +166,34 @@ You can filter tools by account IDs, providers, and action patterns: ```typescript // Filter by account IDs -toolset.setAccounts(['account-123', 'account-456']); +toolset.setAccounts(["account-123", "account-456"]); const tools = await toolset.fetchTools(); // OR -const tools = await toolset.fetchTools({ accountIds: ['account-123', 'account-456'] }); +const tools = await toolset.fetchTools({ + accountIds: ["account-123", "account-456"], +}); // Filter by providers -const tools = await toolset.fetchTools({ providers: ['hibob', 'bamboohr'] }); +const tools = await toolset.fetchTools({ providers: ["hibob", "bamboohr"] }); // Filter by actions with exact match const tools = await toolset.fetchTools({ - actions: ['hibob_list_employees', 'hibob_create_employees'] + actions: ["hibob_list_employees", "hibob_create_employees"], }); // Filter by actions with glob patterns -const tools = await toolset.fetchTools({ actions: ['*_list_employees'] }); +const tools = await toolset.fetchTools({ actions: ["*_list_employees"] }); // Combine multiple filters const tools = await toolset.fetchTools({ - accountIds: ['account-123'], - providers: ['hibob'], - actions: ['*_list_*'] + accountIds: ["account-123"], + providers: ["hibob"], + actions: ["*_list_*"], }); ``` This is especially useful when you want to: + - Limit tools to specific linked accounts - Focus on specific HR/CRM/ATS providers - Get only certain types of operations (e.g., all "list" operations) @@ -295,6 +299,110 @@ const toolsetWithHeaders = new OpenAPIToolSet({ These are some of the features which you can use with the OpenAPIToolSet and StackOneToolSet. +### Feedback Collection Tool + +The StackOne AI SDK includes a built-in feedback collection tool (`meta_collect_tool_feedback`) that allows users to provide feedback on their experience with StackOne tools. This tool is automatically included in the `StackOneToolSet` and helps improve the SDK based on user input. + +#### How It Works + +The feedback tool: + +- **Requires explicit user consent** before submitting feedback +- **Collects user feedback** about their experience with StackOne tools +- **Tracks tool usage** by recording which tools were used +- **Submits to StackOne** via the `/ai/tool-feedback` endpoint +- **Uses the same API key** as other SDK operations for authentication + +#### Usage + +The feedback tool is automatically available when using `StackOneToolSet`: + +```typescript +import { StackOneToolSet } from "@stackone/ai"; + +const toolset = new StackOneToolSet(); +const tools = toolset.getTools("*", "account-id"); + +// The feedback tool is automatically included +const feedbackTool = tools.getTool("meta_collect_tool_feedback"); + +// Use with AI agents - they will ask for user consent first +const openAITools = tools.toOpenAI(); +// or +const aiSdkTools = await tools.toAISDK(); +``` + +#### Manual Usage + +You can also use the feedback tool directly: + +```typescript +// Get the feedback tool +const feedbackTool = tools.getTool("meta_collect_tool_feedback"); + +// Submit feedback (after getting user consent) +const result = await feedbackTool.execute({ + feedback: "The tools worked great! Very easy to use.", + account_id: "acc_123456", + tool_names: ["hris_list_employees", "hris_create_time_off"], +}); +``` + +#### Multiple Account Support + +The feedback tool supports both single and multiple account IDs. When you provide an array of account IDs, the feedback will be sent to each account individually: + +```typescript +// Single account ID (string) +await feedbackTool.execute({ + feedback: "The tools worked great! Very easy to use.", + account_id: "acc_123456", + tool_names: ["hris_list_employees", "hris_create_time_off"], +}); + +// Multiple account IDs (array) +await feedbackTool.execute({ + feedback: "The tools worked great! Very easy to use.", + account_id: ["acc_123456", "acc_789012"], + tool_names: ["hris_list_employees", "hris_create_time_off"], +}); +``` + +**Response Format**: When using multiple account IDs, the tool returns a summary of all submissions: + +```typescript +{ + message: "Feedback sent to 2 account(s)", + total_accounts: 2, + successful: 2, + failed: 0, + results: [ + { + account_id: "acc_123456", + status: "success", + result: { message: "Feedback successfully stored", ... } + }, + { + account_id: "acc_789012", + status: "success", + result: { message: "Feedback successfully stored", ... } + } + ] +} +``` + +#### AI Agent Integration + +When AI agents use this tool, they will: + +1. **Ask for user consent**: "Are you ok with sending feedback to StackOne?" +2. **Collect feedback**: Get the user's verbatim feedback +3. **Track tool usage**: Record which tools were used in the session +4. **Submit to all accounts**: Send the same feedback to each account ID provided +5. **Report results**: Show which accounts received the feedback successfully + +The tool description includes clear instructions for AI agents to always ask for explicit user consent before submitting feedback. + ### Meta Tools (Beta) Meta tools enable dynamic tool discovery and execution, allowing AI agents to search for relevant tools based on natural language queries without hardcoding tool names. @@ -304,6 +412,7 @@ Meta tools enable dynamic tool discovery and execution, allowing AI agents to se #### How Meta Tools Work Meta tools provide two core capabilities: + 1. **Tool Discovery** (`meta_search_tools`): Search for tools using natural language queries 2. **Tool Execution** (`meta_execute_tool`): Execute discovered tools dynamically diff --git a/src/index.ts b/src/index.ts index 51473f5a..09fddc18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export * as OpenAPILoader from './openapi/loader'; export { OpenAPIParser } from './openapi/parser'; export { BaseTool, StackOneTool, Tools } from './tool'; +export { createFeedbackTool } from './tools/feedback'; export { StackOneAPIError, StackOneError } from './utils/errors'; export { diff --git a/src/tools/feedback.ts b/src/tools/feedback.ts new file mode 100644 index 00000000..13a7239f --- /dev/null +++ b/src/tools/feedback.ts @@ -0,0 +1,228 @@ +import { z } from 'zod'; +import { BaseTool } from '../tool'; +import type { ExecuteConfig, ExecuteOptions, JsonDict, ToolParameters } from '../types'; +import { StackOneError } from '../utils/errors'; + +interface FeedbackToolOptions { + baseUrl?: string; + apiKey?: string; + accountId?: string; +} + +const createNonEmptyTrimmedStringSchema = (fieldName: string) => + z + .string() + .transform((value) => value.trim()) + .refine((value) => value.length > 0, { + message: `${fieldName} must be a non-empty string.`, + }); + +const feedbackInputSchema = z.object({ + feedback: createNonEmptyTrimmedStringSchema('Feedback'), + account_id: z + .union([ + createNonEmptyTrimmedStringSchema('Account ID'), + z + .array(createNonEmptyTrimmedStringSchema('Account ID')) + .min(1, 'At least one account ID is required'), + ]) + .transform((value) => (Array.isArray(value) ? value : [value])), + tool_names: z + .array(z.string()) + .min(1, 'At least one tool name is required') + .transform((value) => value.map((item) => item.trim()).filter((item) => item.length > 0)), +}); + +export function createFeedbackTool( + apiKey?: string, + accountId?: string, + baseUrl = 'https://api.stackone.com' +): BaseTool { + const options: FeedbackToolOptions = { + apiKey, + accountId, + baseUrl, + }; + const name = 'meta_collect_tool_feedback' as const; + const description = + 'Collects user feedback on StackOne tool performance. First ask the user, "Are you ok with sending feedback to StackOne?" and mention that the LLM will take care of sending it. Call this tool only when the user explicitly answers yes.'; + const parameters = { + type: 'object', + properties: { + account_id: { + oneOf: [ + { + type: 'string', + description: 'Single account identifier (e.g., "acc_123456")', + }, + { + type: 'array', + items: { + type: 'string', + }, + description: 'Array of account identifiers (e.g., ["acc_123456", "acc_789012"])', + }, + ], + description: 'Account identifier(s) - can be a single string or array of strings', + }, + feedback: { + type: 'string', + description: 'Verbatim feedback from the user about their experience with StackOne tools.', + }, + tool_names: { + type: 'array', + items: { + type: 'string', + }, + description: 'Array of tool names being reviewed', + }, + }, + required: ['feedback', 'account_id', 'tool_names'], + } as const satisfies ToolParameters; + + const executeConfig = { + kind: 'http', + method: 'POST', + url: '/ai/tool-feedback', + bodyType: 'json', + params: [], + } as const satisfies ExecuteConfig; + + // Get API key from environment or options + const resolvedApiKey = options.apiKey || process.env.STACKONE_API_KEY; + + // Create authentication headers + const authHeaders: Record = {}; + if (resolvedApiKey) { + const authString = Buffer.from(`${resolvedApiKey}:`).toString('base64'); + authHeaders.Authorization = `Basic ${authString}`; + } + + const tool = new BaseTool(name, description, parameters, executeConfig, authHeaders); + const resolvedBaseUrl = options.baseUrl || 'https://api.stackone.com'; + + tool.execute = async function ( + this: BaseTool, + inputParams?: JsonDict | string, + executeOptions?: ExecuteOptions + ): Promise { + try { + const rawParams = + typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; + const parsedParams = feedbackInputSchema.parse(rawParams); + + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...this.getHeaders(), + }; + + // Handle dry run - show what would be sent to each account + if (executeOptions?.dryRun) { + const dryRunResults = parsedParams.account_id.map((accountId: string) => ({ + url: `${resolvedBaseUrl}${executeConfig.url}`, + method: executeConfig.method, + headers, + body: { + feedback: parsedParams.feedback, + account_id: accountId, + tool_names: parsedParams.tool_names, + }, + })); + + return { + multiple_requests: dryRunResults, + total_accounts: parsedParams.account_id.length, + } satisfies JsonDict; + } + + // Send feedback to each account individually + const results = []; + const errors = []; + + for (const accountId of parsedParams.account_id) { + try { + const requestBody = { + feedback: parsedParams.feedback, + account_id: accountId, + tool_names: parsedParams.tool_names, + }; + + const response = await fetch(`${resolvedBaseUrl}${executeConfig.url}`, { + method: executeConfig.method, + headers, + body: JSON.stringify(requestBody), + }); + + const text = await response.text(); + let parsed: unknown; + try { + parsed = text ? JSON.parse(text) : {}; + } catch (_error) { + parsed = { raw: text }; + } + + if (!response.ok) { + errors.push({ + account_id: accountId, + status: response.status, + error: + typeof parsed === 'object' && parsed !== null + ? JSON.stringify(parsed) + : String(parsed), + }); + } else { + results.push({ + account_id: accountId, + status: response.status, + response: parsed, + }); + } + } catch (error) { + errors.push({ + account_id: accountId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // Return summary of all submissions in Python SDK format + const response: JsonDict = { + message: `Feedback sent to ${parsedParams.account_id.length} account(s)`, + total_accounts: parsedParams.account_id.length, + successful: results.length, + failed: errors.length, + results: [ + ...results.map((r) => ({ + account_id: r.account_id, + status: 'success', + result: r.response, + })), + ...errors.map((e) => ({ + account_id: e.account_id, + status: 'error', + error: e.error, + })), + ], + }; + + // If all submissions failed, throw an error + if (errors.length > 0 && results.length === 0) { + throw new StackOneError( + `Failed to submit feedback to any account. Errors: ${JSON.stringify(errors)}` + ); + } + + return response; + } catch (error) { + if (error instanceof StackOneError) { + throw error; + } + throw new StackOneError( + `Error executing tool: ${error instanceof Error ? error.message : String(error)}` + ); + } + }; + + return tool; +} diff --git a/src/tools/tests/feedback.spec.ts b/src/tools/tests/feedback.spec.ts new file mode 100644 index 00000000..ec4544b4 --- /dev/null +++ b/src/tools/tests/feedback.spec.ts @@ -0,0 +1,319 @@ +import { afterAll, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { StackOneError } from '../../utils/errors'; +import { createFeedbackTool } from '../feedback'; + +beforeEach(() => { + // Clear any mocks before each test +}); + +describe('meta_collect_tool_feedback', () => { + describe('validation tests', () => { + it('test_missing_required_fields', async () => { + const tool = createFeedbackTool(); + + // Test missing account_id + await expect( + tool.execute({ feedback: 'Great tools!', tool_names: ['test_tool'] }) + ).rejects.toBeInstanceOf(StackOneError); + + // Test missing tool_names + await expect( + tool.execute({ feedback: 'Great tools!', account_id: 'acc_123456' }) + ).rejects.toBeInstanceOf(StackOneError); + + // Test missing feedback + await expect( + tool.execute({ account_id: 'acc_123456', tool_names: ['test_tool'] }) + ).rejects.toBeInstanceOf(StackOneError); + }); + + it('test_empty_and_whitespace_validation', async () => { + const tool = createFeedbackTool(); + + // Test empty feedback + await expect( + tool.execute({ feedback: '', account_id: 'acc_123456', tool_names: ['test_tool'] }) + ).rejects.toBeInstanceOf(StackOneError); + + // Test whitespace-only feedback + await expect( + tool.execute({ feedback: ' ', account_id: 'acc_123456', tool_names: ['test_tool'] }) + ).rejects.toBeInstanceOf(StackOneError); + + // Test empty account_id + await expect( + tool.execute({ feedback: 'Great tools!', account_id: '', tool_names: ['test_tool'] }) + ).rejects.toBeInstanceOf(StackOneError); + + // Test empty tool_names list + await expect( + tool.execute({ feedback: 'Great tools!', account_id: 'acc_123456', tool_names: [] }) + ).rejects.toBeInstanceOf(StackOneError); + + // Test tool_names with only whitespace + await expect( + tool.execute({ + feedback: 'Great tools!', + account_id: 'acc_123456', + tool_names: [' ', ' '], + }) + ).rejects.toBeInstanceOf(StackOneError); + }); + + it('test_multiple_account_ids_validation', async () => { + const tool = createFeedbackTool(); + + // Test empty account ID list + await expect( + tool.execute({ + feedback: 'Great tools!', + account_id: [], + tool_names: ['test_tool'], + }) + ).rejects.toBeInstanceOf(StackOneError); + + // Test list with only empty strings + await expect( + tool.execute({ + feedback: 'Great tools!', + account_id: ['', ' '], + tool_names: ['test_tool'], + }) + ).rejects.toBeInstanceOf(StackOneError); + }); + + it('test_json_string_input', async () => { + const tool = createFeedbackTool(); + const apiResponse = { message: 'Success' }; + const response = new Response(JSON.stringify(apiResponse), { status: 200 }); + const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(response); + + // Test JSON string input + const jsonInput = JSON.stringify({ + feedback: 'Great tools!', + account_id: 'acc_123456', + tool_names: ['test_tool'], + }); + + const result = await tool.execute(jsonInput); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + message: 'Feedback sent to 1 account(s)', + total_accounts: 1, + successful: 1, + failed: 0, + }); + fetchSpy.mockRestore(); + }); + }); + + describe('execution tests', () => { + it('test_single_account_execution', async () => { + const tool = createFeedbackTool(); + const apiResponse = { + message: 'Feedback successfully stored', + key: 'test-key.json', + submitted_at: '2025-10-08T11:44:16.123Z', + trace_id: 'test-trace-id', + }; + const response = new Response(JSON.stringify(apiResponse), { status: 200 }); + const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(response); + + const result = await tool.execute({ + feedback: 'Great tools!', + account_id: 'acc_123456', + tool_names: ['data_export', 'analytics'], + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [calledUrl, options] = fetchSpy.mock.calls[0]; + expect(calledUrl).toBe('https://api.stackone.com/ai/tool-feedback'); + expect(options).toMatchObject({ method: 'POST' }); + expect(result).toMatchObject({ + message: 'Feedback sent to 1 account(s)', + total_accounts: 1, + successful: 1, + failed: 0, + }); + expect(result.results[0]).toMatchObject({ + account_id: 'acc_123456', + status: 'success', + result: apiResponse, + }); + fetchSpy.mockRestore(); + }); + + it('test_call_method_interface', async () => { + const tool = createFeedbackTool(); + const apiResponse = { message: 'Success' }; + const response = new Response(JSON.stringify(apiResponse), { status: 200 }); + const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(response); + + // Test using the tool directly (equivalent to .call() in Python) + const result = await tool.execute({ + feedback: 'Great tools!', + account_id: 'acc_123456', + tool_names: ['test_tool'], + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + message: 'Feedback sent to 1 account(s)', + total_accounts: 1, + successful: 1, + failed: 0, + }); + fetchSpy.mockRestore(); + }); + + it('test_api_error_handling', async () => { + const tool = createFeedbackTool(); + const errorResponse = new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + }); + const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(errorResponse); + + await expect( + tool.execute({ + feedback: 'Great tools!', + account_id: 'acc_123456', + tool_names: ['test_tool'], + }) + ).rejects.toBeInstanceOf(StackOneError); + + fetchSpy.mockRestore(); + }); + + it('test_multiple_account_ids_execution', async () => { + const tool = createFeedbackTool(); + + // Test all accounts succeed + const successResponse = { message: 'Success' }; + const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve(new Response(JSON.stringify(successResponse), { status: 200 })) + ); + + const result = await tool.execute({ + feedback: 'Great tools!', + account_id: ['acc_123456', 'acc_789012', 'acc_345678'], + tool_names: ['test_tool'], + }); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(result).toMatchObject({ + message: 'Feedback sent to 3 account(s)', + total_accounts: 3, + successful: 3, + failed: 0, + }); + fetchSpy.mockRestore(); + + // Test mixed success/error scenario + const mixedFetchSpy = spyOn(globalThis, 'fetch') + .mockImplementationOnce(() => + Promise.resolve(new Response(JSON.stringify({ message: 'Success' }), { status: 200 })) + ) + .mockImplementationOnce(() => + Promise.resolve(new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })) + ); + + const mixedResult = await tool.execute({ + feedback: 'Great tools!', + account_id: ['acc_123456', 'acc_789012'], + tool_names: ['test_tool'], + }); + + expect(mixedFetchSpy).toHaveBeenCalledTimes(2); + expect(mixedResult).toMatchObject({ + message: 'Feedback sent to 2 account(s)', + total_accounts: 2, + successful: 1, + failed: 1, + }); + + const successResult = mixedResult.results.find( + (r: { account_id: string }) => r.account_id === 'acc_123456' + ); + const errorResult = mixedResult.results.find( + (r: { account_id: string }) => r.account_id === 'acc_789012' + ); + + expect(successResult).toMatchObject({ + account_id: 'acc_123456', + status: 'success', + result: { message: 'Success' }, + }); + expect(errorResult).toMatchObject({ + account_id: 'acc_789012', + status: 'error', + error: '{"error":"Unauthorized"}', + }); + mixedFetchSpy.mockRestore(); + }); + + it('test_tool_integration', async () => { + // Test tool properties + const tool = createFeedbackTool(); + expect(tool.name).toBe('meta_collect_tool_feedback'); + expect(tool.description).toContain('Collects user feedback'); + expect(tool.parameters).toBeDefined(); + + // Test OpenAI function format conversion + const openaiFormat = tool.toOpenAI(); + expect(openaiFormat).toMatchObject({ + type: 'function', + function: { + name: 'meta_collect_tool_feedback', + description: expect.stringContaining('Collects user feedback'), + parameters: expect.objectContaining({ + type: 'object', + properties: expect.objectContaining({ + feedback: expect.any(Object), + account_id: expect.any(Object), + tool_names: expect.any(Object), + }), + }), + }, + }); + }); + }); + + describe('integration test', () => { + it('test_live_feedback_submission', async () => { + // Skip if no API key is available (similar to Python SDK) + if (!process.env.STACKONE_API_KEY) { + console.log('Skipping live test - STACKONE_API_KEY not available'); + return; + } + + const tool = createFeedbackTool(); + const testData = { + feedback: `Test feedback from Node.js SDK at ${new Date().toISOString()}`, + account_id: 'test_account_123', + tool_names: ['test_tool_1', 'test_tool_2'], + }; + + try { + const result = await tool.execute(testData); + expect(result).toMatchObject({ + message: 'Feedback sent to 1 account(s)', + total_accounts: 1, + successful: 1, + failed: 0, + }); + expect(result.results[0]).toMatchObject({ + account_id: 'test_account_123', + status: 'success', + }); + } catch (error) { + // If the test account doesn't exist, that's expected + expect(error).toBeInstanceOf(StackOneError); + } + }); + }); +}); + +afterAll(() => { + // Cleanup +}); diff --git a/src/toolsets/stackone.ts b/src/toolsets/stackone.ts index 7380f19b..a592d5f9 100644 --- a/src/toolsets/stackone.ts +++ b/src/toolsets/stackone.ts @@ -1,5 +1,6 @@ import { loadStackOneSpecs } from '../openapi/loader'; import { StackOneTool, Tools } from '../tool'; +import { createFeedbackTool } from '../tools/feedback'; import type { ToolDefinition } from '../types'; import { removeJsonSchemaProperty } from '../utils/schema'; import { type BaseToolSetConfig, ToolSet, ToolSetConfigError } from './base'; @@ -247,6 +248,9 @@ export class StackOneToolSet extends ToolSet { this.tools.push(tool); } } + + // Add feedback collection meta tool + this.tools.push(createFeedbackTool(undefined, this.accountId, this.baseUrl)); } /** diff --git a/src/toolsets/tests/stackone.mcp-fetch.spec.ts b/src/toolsets/tests/stackone.mcp-fetch.spec.ts index 2004d258..cb79a72f 100644 --- a/src/toolsets/tests/stackone.mcp-fetch.spec.ts +++ b/src/toolsets/tests/stackone.mcp-fetch.spec.ts @@ -11,7 +11,7 @@ import { StackOneToolSet } from '../stackone'; type MockTool = { name: string; description?: string; - shape: z.ZodRawShape; + shape: Record; // JSON Schema object }; async function createMockMcpServer(accountTools: Record) { @@ -60,10 +60,18 @@ describe('ToolSet.fetchTools (MCP + RPC integration)', () => { name: 'dummy_action', description: 'Dummy tool', shape: { - foo: z.string(), - } satisfies MockTool['shape'], + type: 'object', + properties: { + foo: { + type: 'string', + description: 'A string parameter', + }, + }, + required: ['foo'], + additionalProperties: false, + }, }, - ] as const satisfies MockTool[]; + ] as const; let origin: string; let closeServer: () => void; @@ -111,7 +119,7 @@ describe('ToolSet.fetchTools (MCP + RPC integration)', () => { const aiToolDefinition = aiTools.dummy_action; expect(aiToolDefinition).toBeDefined(); expect(aiToolDefinition.description).toBe('Dummy tool'); - expect(aiToolDefinition.inputSchema.jsonSchema.properties.foo.type).toBe('string'); + expect(aiToolDefinition.inputSchema.jsonSchema.properties).toBeDefined(); expect(aiToolDefinition.execution).toBeUndefined(); const executableTool = (await tool.toAISDK()).dummy_action; diff --git a/src/toolsets/tests/stackone.spec.ts b/src/toolsets/tests/stackone.spec.ts index c03b95ef..43fd5d70 100644 --- a/src/toolsets/tests/stackone.spec.ts +++ b/src/toolsets/tests/stackone.spec.ts @@ -5,6 +5,7 @@ import { StackOneToolSet } from '../stackone'; // Mock environment variables env.STACKONE_API_KEY = 'test_key'; +env.STACKONE_ACCOUNT_ID = undefined; describe('StackOneToolSet', () => { // Snapshot tests