From 7e86256bbb303c0e28b1d7b2b6978d7a957a9d75 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:50:32 +0000 Subject: [PATCH 1/5] refactor(types): replace JsonDict with type-fest JsonObject and JsonValue Remove the custom `JsonDict = Record` type alias in favour of type-fest's stricter `JsonObject` and `JsonValue` types. Key changes to src/types.ts: - Import and re-export `JsonObject` and `JsonValue` from type-fest - Update `JSONSchema` interface to use `JsonValue` instead of `unknown` for properties like `enum`, `const`, `default`, and `examples` - Add explicit index signature union type for JSONSchema extensibility This provides stronger type safety by ensuring values are actually JSON-serialisable rather than accepting arbitrary `unknown` values. --- src/index.ts | 3 ++- src/types.ts | 22 ++++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index df1de9b..a20fc6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,8 @@ export type { AISDKToolResult, ExecuteConfig, ExecuteOptions, - JsonDict, + JsonObject, + JsonValue, ParameterLocation, ToolDefinition, } from './types'; diff --git a/src/types.ts b/src/types.ts index f1fc58b..f531b1b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,12 +4,9 @@ import type { Tool } from '@ai-sdk/provider-utils'; import type { ToolSet } from 'ai'; -import type { ValueOf } from 'type-fest'; +import type { JsonObject, JsonValue, ValueOf } from 'type-fest'; -/** - * Generic dictionary type for JSON-compatible objects - */ -export type JsonDict = Record; +export type { JsonObject, JsonValue }; /** * HTTP headers type @@ -27,10 +24,10 @@ export interface JSONSchema { properties?: Record; items?: JSONSchema | Array; required?: Array; - enum?: Array; - const?: unknown; + enum?: Array; + const?: JsonValue; description?: string; - default?: unknown; + default?: JsonValue; $ref?: string; $defs?: Record; definitions?: Record; @@ -59,8 +56,13 @@ export interface JSONSchema { minProperties?: number; maxProperties?: number; title?: string; - examples?: Array; - [key: string]: unknown; // Allow additional properties for extensibility + examples?: Array; + [key: string]: + | JsonValue + | JSONSchema + | Array + | Record + | undefined; // Allow additional properties for extensibility } /** From a58fd3d50394d85c71b6e82783ad1e5dc179c13c Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:50:47 +0000 Subject: [PATCH 2/5] refactor(core): update source files to use JsonObject type Migrate all source files from the removed `JsonDict` type to the type-fest `JsonObject` type. This commit updates method signatures and internal type annotations across the codebase. Files updated: - src/tool.ts: Update execute method signatures and meta tool returns - src/toolsets.ts: Update parameter and return types - src/feedback.ts: Add explicit array types for results/errors, use JsonValue for parsed responses - src/headers.ts: Update normaliseHeaders parameter type Notable implementation changes: - meta_search_tools now uses JSON.parse(JSON.stringify(...)) to ensure the return value is JSON-serialisable (removes undefined values from JSONSchema objects) - Feedback tool now explicitly types its internal arrays to ensure type safety with the stricter JsonObject constraint --- src/feedback.ts | 20 ++++++++++---------- src/headers.ts | 8 ++++---- src/tool.ts | 18 +++++++++--------- src/toolsets.ts | 30 +++++++++++++++--------------- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/feedback.ts b/src/feedback.ts index c3584b3..d564df2 100644 --- a/src/feedback.ts +++ b/src/feedback.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { DEFAULT_BASE_URL } from './consts'; import { BaseTool } from './tool'; -import type { ExecuteConfig, ExecuteOptions, JsonDict, ToolParameters } from './types'; +import type { ExecuteConfig, ExecuteOptions, JsonObject, JsonValue, ToolParameters } from './types'; import { StackOneError } from './utils/errors'; interface FeedbackToolOptions { @@ -107,9 +107,9 @@ export function createFeedbackTool( tool.execute = async function ( this: BaseTool, - inputParams?: JsonDict | string, + inputParams?: JsonObject | string, executeOptions?: ExecuteOptions, - ): Promise { + ): Promise { try { const rawParams = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; @@ -137,12 +137,12 @@ export function createFeedbackTool( return { multiple_requests: dryRunResults, total_accounts: parsedParams.account_id.length, - } satisfies JsonDict; + } satisfies JsonObject; } // Send feedback to each account individually - const results = []; - const errors = []; + const results: Array<{ account_id: string; status: number; response: JsonValue }> = []; + const errors: Array<{ account_id: string; status?: number; error: string }> = []; for (const accountId of parsedParams.account_id) { try { @@ -159,9 +159,9 @@ export function createFeedbackTool( }); const text = await response.text(); - let parsed: unknown; + let parsed: JsonValue; try { - parsed = text ? JSON.parse(text) : {}; + parsed = text ? (JSON.parse(text) satisfies JsonValue) : {}; } catch { parsed = { raw: text }; } @@ -191,7 +191,7 @@ export function createFeedbackTool( } // Return summary of all submissions in Python SDK format - const response: JsonDict = { + const response = { message: `Feedback sent to ${parsedParams.account_id.length} account(s)`, total_accounts: parsedParams.account_id.length, successful: results.length, @@ -208,7 +208,7 @@ export function createFeedbackTool( error: e.error, })), ], - }; + } satisfies JsonObject; // If all submissions failed, throw an error if (errors.length > 0 && results.length === 0) { diff --git a/src/headers.ts b/src/headers.ts index 1107483..7e412f3 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -1,5 +1,5 @@ import { z } from 'zod/mini'; -import type { JsonDict } from './types'; +import type { JsonObject } from './types'; /** * Known StackOne API header keys that are forwarded as HTTP headers @@ -18,13 +18,13 @@ export const stackOneHeadersSchema = z.record(z.string(), z.string()).brand<'Sta export type StackOneHeaders = z.infer; /** - * Normalises header values from JsonDict to StackOneHeaders (branded type) + * Normalises header values from JsonObject to StackOneHeaders (branded type) * Converts numbers and booleans to strings, and serialises objects to JSON * - * @param headers - Headers object with unknown value types + * @param headers - Headers object with JSON value types * @returns Normalised headers with string values only (branded type) */ -export function normaliseHeaders(headers: JsonDict | undefined): StackOneHeaders { +export function normaliseHeaders(headers: JsonObject | undefined): StackOneHeaders { if (!headers) return stackOneHeadersSchema.parse({}); const result: Record = {}; for (const [key, value] of Object.entries(headers)) { diff --git a/src/tool.ts b/src/tool.ts index 154d397..39b2dfc 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -12,7 +12,7 @@ import type { ExecuteConfig, ExecuteOptions, HttpExecuteConfig, - JsonDict, + JsonObject, JSONSchema, LocalExecuteConfig, RpcExecuteConfig, @@ -130,7 +130,7 @@ export class BaseTool { /** * Execute the tool with the provided parameters */ - async execute(inputParams?: JsonDict | string, options?: ExecuteOptions): Promise { + async execute(inputParams?: JsonObject | string, options?: ExecuteOptions): Promise { try { if (!this.requestBuilder || this.executeConfig.kind !== 'http') { // Non-HTTP tools provide their own execute override (e.g. RPC, local meta tools). @@ -255,7 +255,7 @@ export class BaseTool { (options.executable ?? true) ? async (args: Record) => { try { - return await this.execute(args as JsonDict); + return await this.execute(args as JsonObject); } catch (error) { return `Error executing tool: ${error instanceof Error ? error.message : String(error)}`; } @@ -573,7 +573,7 @@ function metaSearchTools( } as const satisfies LocalExecuteConfig; const tool = new BaseTool(name, description, parameters, executeConfig); - tool.execute = async (inputParams?: JsonDict | string): Promise => { + tool.execute = async (inputParams?: JsonObject | string): Promise => { try { // Validate params is either undefined, string, or object if ( @@ -639,18 +639,18 @@ function metaSearchTools( const tool = allTools.find((t) => t.name === r.name); if (!tool) return null; - const result: MetaToolSearchResult = { + return { name: tool.name, description: tool.description, parameters: tool.parameters, score: r.score, }; - return result; }) .filter((t): t is MetaToolSearchResult => t !== null) .slice(0, limit); - return { tools: toolConfigs } satisfies JsonDict; + // Convert to JSON-serialisable format (removes undefined values) + return JSON.parse(JSON.stringify({ tools: toolConfigs })) satisfies JsonObject; } catch (error) { if (error instanceof StackOneError) { throw error; @@ -701,9 +701,9 @@ function metaExecuteTool(tools: Tools): BaseTool { // Override the execute method to handle tool execution // receives tool name and parameters and executes the tool tool.execute = async ( - inputParams?: JsonDict | string, + inputParams?: JsonObject | string, options?: ExecuteOptions, - ): Promise => { + ): Promise => { try { // Validate params is either undefined, string, or object if ( diff --git a/src/toolsets.ts b/src/toolsets.ts index b15d82d..27b9c0e 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -8,7 +8,7 @@ import { type RpcActionResponse, RpcClient } from './rpc-client'; import { BaseTool, Tools } from './tool'; import type { ExecuteOptions, - JsonDict, + JsonObject, JsonSchemaProperties, RpcExecuteConfig, ToolParameters, @@ -17,17 +17,17 @@ import { toArray } from './utils/array'; import { StackOneError } from './utils/errors'; /** - * Converts RpcActionResponse to JsonDict in a type-safe manner. + * Converts RpcActionResponse to JsonObject in a type-safe manner. * RpcActionResponse uses z.passthrough() which preserves additional fields, - * making it structurally compatible with Record. + * making it structurally compatible with Record. */ -function rpcResponseToJsonDict(response: RpcActionResponse): JsonDict { +function rpcResponseToJsonObject(response: RpcActionResponse): JsonObject { // RpcActionResponse with passthrough() has the shape: // { next?: string | null, data?: ..., [key: string]: unknown } // We extract all properties into a plain object - const result: JsonDict = {}; + const result: JsonObject = {}; for (const [key, value] of Object.entries(response)) { - result[key] = value; + result[key] = value as JsonObject[string]; } return result; } @@ -460,9 +460,9 @@ export class StackOneToolSet { ).setExposeExecutionMetadata(false); tool.execute = async ( - inputParams?: JsonDict | string, + inputParams?: JsonObject | string, options?: ExecuteOptions, - ): Promise => { + ): Promise => { try { if ( inputParams !== undefined && @@ -488,12 +488,12 @@ export class StackOneToolSet { const actionHeaders = defu(extraHeaders, baseHeaders) as StackOneHeaders; const bodyPayload = this.extractRecord(parsedParams, 'body'); - const rpcBody: JsonDict = bodyPayload ? { ...bodyPayload } : {}; + const rpcBody: JsonObject = bodyPayload ? { ...bodyPayload } : {}; for (const [key, value] of Object.entries(parsedParams)) { if (key === 'body' || key === 'headers' || key === 'path' || key === 'query') { continue; } - rpcBody[key] = value as unknown; + rpcBody[key] = value as JsonObject[string]; } if (options?.dryRun) { @@ -511,7 +511,7 @@ export class StackOneToolSet { headers: actionHeaders, body: JSON.stringify(requestPayload), mappedParams: parsedParams, - } satisfies JsonDict; + } satisfies JsonObject; } const response = await actionsClient.actions.rpcAction({ @@ -522,7 +522,7 @@ export class StackOneToolSet { query: queryParams ?? undefined, }); - return rpcResponseToJsonDict(response); + return rpcResponseToJsonObject(response); } catch (error) { if (error instanceof StackOneError) { throw error; @@ -545,12 +545,12 @@ export class StackOneToolSet { } private extractRecord( - params: JsonDict, + params: JsonObject, key: 'body' | 'headers' | 'path' | 'query', - ): JsonDict | undefined { + ): JsonObject | undefined { const value = params[key]; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - return value as JsonDict; + return value as JsonObject; } return undefined; } From f7ce1230cf83a501f06dbb62404f19f65587393c Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:51:00 +0000 Subject: [PATCH 3/5] refactor(requestBuilder): add stringifyValue helper for type-safe serialisation Add a dedicated `stringifyValue()` function to safely convert JsonValue types to strings for use in URLs, headers, and form data. This replaces direct `String()` calls which could produce `[object Object]` for complex values. The helper function: - Returns strings as-is - Converts numbers and booleans via String() - Returns empty string for null values - JSON-stringifies arrays and objects Also updates the dry run response to properly serialise headers and body to JSON-compatible values using `satisfies JsonObject` for type checking. --- src/requestBuilder.ts | 65 +++++++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/src/requestBuilder.ts b/src/requestBuilder.ts index c729a88..2549a09 100644 --- a/src/requestBuilder.ts +++ b/src/requestBuilder.ts @@ -1,9 +1,10 @@ +import type { JsonValue } from 'type-fest'; import { USER_AGENT } from './consts'; import { type ExecuteOptions, type HttpBodyType, type HttpExecuteConfig, - type JsonDict, + type JsonObject, ParameterLocation, } from './types'; import { StackOneAPIError } from './utils/errors'; @@ -20,6 +21,24 @@ class ParameterSerializationError extends Error { } } +/** + * Convert a JsonValue to a string representation suitable for URLs and form data. + * Objects and arrays are JSON-stringified, primitives are converted directly. + */ +function stringifyValue(value: JsonValue): string { + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (value === null) { + return ''; + } + // Arrays and objects + return JSON.stringify(value); +} + /** * Builds and executes HTTP requests for tools declared with kind:'http'. */ @@ -66,10 +85,10 @@ export class RequestBuilder { /** * Prepare URL and parameters for the API request */ - prepareRequestParams(params: JsonDict): [string, JsonDict, JsonDict] { + prepareRequestParams(params: JsonObject): [string, JsonObject, JsonObject] { let url = this.url; - const bodyParams: JsonDict = {}; - const queryParams: JsonDict = {}; + const bodyParams: JsonObject = {}; + const queryParams: JsonObject = {}; for (const [key, value] of Object.entries(params)) { // Find the parameter configuration in the params array @@ -79,7 +98,7 @@ export class RequestBuilder { switch (paramLocation) { case ParameterLocation.PATH: // Replace path parameter in URL - url = url.replace(`{${key}}`, encodeURIComponent(String(value))); + url = url.replace(`{${key}}`, encodeURIComponent(stringifyValue(value))); break; case ParameterLocation.QUERY: // Add to query parameters @@ -87,7 +106,7 @@ export class RequestBuilder { break; case ParameterLocation.HEADER: // Add to headers - this.headers[key] = String(value); + this.headers[key] = stringifyValue(value); break; case ParameterLocation.BODY: // Add to body parameters @@ -107,7 +126,7 @@ export class RequestBuilder { /** * Build the fetch options for the request */ - buildFetchOptions(bodyParams: JsonDict): RequestInit { + buildFetchOptions(bodyParams: JsonObject): RequestInit { const headers = this.prepareHeaders(); const fetchOptions: RequestInit = { method: this.method, @@ -134,7 +153,7 @@ export class RequestBuilder { }; const formBody = new URLSearchParams(); for (const [key, value] of Object.entries(bodyParams)) { - formBody.append(key, String(value)); + formBody.append(key, stringifyValue(value)); } fetchOptions.body = formBody.toString(); break; @@ -143,7 +162,7 @@ export class RequestBuilder { // Handle file uploads const formData = new FormData(); for (const [key, value] of Object.entries(bodyParams)) { - formData.append(key, String(value)); + formData.append(key, stringifyValue(value)); } fetchOptions.body = formData; // Don't set Content-Type for FormData, it will be set automatically with the boundary @@ -276,7 +295,7 @@ export class RequestBuilder { /** * Builds all query parameters with optimized batching */ - private buildQueryParameters(queryParams: JsonDict): [string, string][] { + private buildQueryParameters(queryParams: JsonObject): [string, string][] { const allParams: [string, string][] = []; for (const [key, value] of Object.entries(queryParams)) { @@ -295,7 +314,7 @@ export class RequestBuilder { /** * Execute the request */ - async execute(params: JsonDict, options?: ExecuteOptions): Promise { + async execute(params: JsonObject, options?: ExecuteOptions): Promise { // Prepare request parameters const [url, bodyParams, queryParams] = this.prepareRequestParams(params); @@ -313,13 +332,29 @@ export class RequestBuilder { // If dryRun is true, return the request details instead of making the API call if (options?.dryRun) { + // Convert headers to a plain object for JSON serialisation + const headersObj = + fetchOptions.headers instanceof Headers + ? Object.fromEntries(fetchOptions.headers.entries()) + : Array.isArray(fetchOptions.headers) + ? Object.fromEntries(fetchOptions.headers) + : (fetchOptions.headers ?? {}); + + // Convert body to a JSON-serialisable value + const bodyValue = + fetchOptions.body instanceof FormData + ? '[FormData]' + : typeof fetchOptions.body === 'string' + ? fetchOptions.body + : null; + return { url: urlWithQuery.toString(), method: this.method, - headers: fetchOptions.headers, - body: fetchOptions.body instanceof FormData ? '[FormData]' : fetchOptions.body, + headers: headersObj, + body: bodyValue, mappedParams: params, - }; + } satisfies JsonObject; } // Execute the request @@ -337,6 +372,6 @@ export class RequestBuilder { } // Parse the response - return (await response.json()) as JsonDict; + return (await response.json()) as JsonObject; } } From 7f106cc5e79b2dad9e9ab8e901c2d1b5287ecee5 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:51:14 +0000 Subject: [PATCH 4/5] test: update tests for JsonObject type changes Update test files to work with the stricter JsonObject type: src/tool.test.ts: - Add isMetaToolSearchResults type guard function - Add getSearchResults helper to safely extract search results - Replace direct type assertions with type-safe helper calls src/requestBuilder.test.ts: - Update dry run test expectation: body is now `null` instead of `undefined` when no body params exist (null is JSON-serialisable) - Add explicit type casts for runtime edge case tests that intentionally pass invalid types (Date, RegExp, circular refs) src/headers.test.ts: - Remove test for undefined values (not valid in JsonObject) - Keep null value test (null is a valid JSON value) --- src/headers.test.ts | 8 ------- src/requestBuilder.test.ts | 43 +++++++++++++++++---------------- src/tool.test.ts | 49 +++++++++++++++++++++++++++++--------- 3 files changed, 61 insertions(+), 39 deletions(-) diff --git a/src/headers.test.ts b/src/headers.test.ts index fd80e15..6a59343 100644 --- a/src/headers.test.ts +++ b/src/headers.test.ts @@ -1,4 +1,3 @@ -import { describe, expect, it } from 'vitest'; import { normaliseHeaders } from './headers'; describe('normaliseHeaders', () => { @@ -43,12 +42,6 @@ describe('normaliseHeaders', () => { }); }); - it('skips undefined values', () => { - expect(normaliseHeaders({ foo: 'bar', baz: undefined })).toEqual({ - foo: 'bar', - }); - }); - it('skips null values', () => { expect(normaliseHeaders({ foo: 'bar', baz: null })).toEqual({ foo: 'bar', @@ -64,7 +57,6 @@ describe('normaliseHeaders', () => { object: { nested: 'value' }, array: [1, 2, 3], nullValue: null, - undefinedValue: undefined, }), ).toEqual({ string: 'text', diff --git a/src/requestBuilder.test.ts b/src/requestBuilder.test.ts index 084fd2b..028937e 100644 --- a/src/requestBuilder.test.ts +++ b/src/requestBuilder.test.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from 'msw'; import { server } from '../mocks/node'; -import { type HttpExecuteConfig, ParameterLocation } from './types'; +import { type HttpExecuteConfig, type JsonObject, ParameterLocation } from './types'; import { StackOneAPIError } from './utils/errors'; import { RequestBuilder } from './requestBuilder'; @@ -173,7 +173,7 @@ describe('RequestBuilder', () => { 'User-Agent': 'stackone-ai-node', 'Initial-Header': 'test', }, - body: undefined, + body: null, mappedParams: params, }); expect(recordedRequests).toHaveLength(0); @@ -227,13 +227,12 @@ describe('RequestBuilder', () => { expect(url.searchParams.get('proxy')).toBeNull(); }); - it('should handle null and undefined values in deep objects', async () => { + it('should handle null values in deep objects', async () => { const params = { pathParam: 'test-value', filter: { valid_field: 'value', null_field: null, - undefined_field: undefined, empty_string: '', zero: 0, false_value: false, @@ -249,9 +248,8 @@ describe('RequestBuilder', () => { expect(url.searchParams.get('filter[zero]')).toBe('0'); expect(url.searchParams.get('filter[false_value]')).toBe('false'); - // Check that null and undefined values are excluded + // Check that null values are excluded expect(url.searchParams.get('filter[null_field]')).toBeNull(); - expect(url.searchParams.get('filter[undefined_field]')).toBeNull(); }); it('should apply deep object serialization to all object parameters', async () => { @@ -311,7 +309,7 @@ describe('RequestBuilder', () => { describe('Security and Performance Improvements', () => { it('should throw error when recursion depth limit is exceeded', async () => { // Create a deeply nested object that exceeds the default depth limit of 10 - let deepObject: Record = { value: 'test' }; + let deepObject: JsonObject = { value: 'test' }; for (let i = 0; i < 12; i++) { deepObject = { nested: deepObject }; } @@ -319,7 +317,7 @@ describe('RequestBuilder', () => { const params = { pathParam: 'test-value', deepFilter: deepObject, - }; + } satisfies JsonObject; await expect(builder.execute(params, { dryRun: true })).rejects.toThrow( 'Maximum nesting depth (10) exceeded for parameter serialization', @@ -327,6 +325,8 @@ describe('RequestBuilder', () => { }); it('should throw error when circular reference is detected', async () => { + // Test runtime behaviour when circular reference is passed + // Note: This tests error handling for malformed input at runtime const inner: Record = { b: 'test' }; const circular: Record = { a: inner }; inner.circular = circular; // Create circular reference @@ -334,7 +334,7 @@ describe('RequestBuilder', () => { const params = { pathParam: 'test-value', filter: circular, - }; + } as unknown as JsonObject; // Cast to test runtime error handling await expect(builder.execute(params, { dryRun: true })).rejects.toThrow( 'Circular reference detected in parameter object', @@ -355,7 +355,10 @@ describe('RequestBuilder', () => { ); }); - it('should handle special types correctly', async () => { + it('should handle special types correctly at runtime', async () => { + // Test runtime behaviour when non-JSON types are passed + // Note: Date and RegExp are not valid JsonValue types, but we test + // the serialiser's runtime handling of these edge cases const testDate = new Date('2023-01-01T00:00:00.000Z'); const testRegex = /test-pattern/gi; @@ -365,10 +368,9 @@ describe('RequestBuilder', () => { dateField: testDate, regexField: testRegex, nullField: null, - undefinedField: undefined, emptyString: '', }, - }; + } as unknown as JsonObject; // Cast to test runtime serialisation const result = await builder.execute(params, { dryRun: true }); const url = new URL(result.url as string); @@ -379,22 +381,22 @@ describe('RequestBuilder', () => { // RegExp should be serialized to string representation expect(url.searchParams.get('filter[regexField]')).toBe('/test-pattern/gi'); - // Null and undefined should result in empty string (but won't be added since they're filtered out) + // Null should be filtered out expect(url.searchParams.get('filter[nullField]')).toBeNull(); - expect(url.searchParams.get('filter[undefinedField]')).toBeNull(); // Empty string should be preserved expect(url.searchParams.get('filter[emptyString]')).toBe(''); }); - it('should throw error when trying to serialize functions', async () => { + it('should throw error when trying to serialize functions at runtime', async () => { + // Test runtime error handling when functions are passed const params = { pathParam: 'test-value', filter: { validField: 'test', functionField: () => 'test', // Functions should not be serializable }, - }; + } as unknown as JsonObject; // Cast to test runtime error handling await expect(builder.execute(params, { dryRun: true })).rejects.toThrow( 'Functions cannot be serialized as parameters', @@ -441,7 +443,8 @@ describe('RequestBuilder', () => { expect(url.searchParams.get('filter[mixed]')).toBe('["string",42,true]'); }); - it('should handle nested objects with special types', async () => { + it('should handle nested objects with special types at runtime', async () => { + // Test runtime serialisation of nested non-JSON types const params = { pathParam: 'test-value', filter: { @@ -453,7 +456,7 @@ describe('RequestBuilder', () => { }, }, }, - }; + } as unknown as JsonObject; // Cast to test runtime serialisation const result = await builder.execute(params, { dryRun: true }); const url = new URL(result.url as string); @@ -465,7 +468,7 @@ describe('RequestBuilder', () => { it('should maintain performance with large objects', async () => { // Create a moderately large object to test performance optimizations - const largeFilter: Record = {}; + const largeFilter: Record = {}; for (let i = 0; i < 100; i++) { largeFilter[`field_${i}`] = `value_${i}`; if (i % 10 === 0) { @@ -479,7 +482,7 @@ describe('RequestBuilder', () => { const params = { pathParam: 'test-value', filter: largeFilter, - }; + } satisfies JsonObject; const startTime = performance.now(); const result = await builder.execute(params, { dryRun: true }); diff --git a/src/tool.test.ts b/src/tool.test.ts index b1961da..319699f 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -1,5 +1,32 @@ import { jsonSchema } from 'ai'; import { BaseTool, type MetaToolSearchResult, StackOneTool, Tools } from './tool'; +import type { JsonObject } from './types'; + +/** + * Type guard for MetaToolSearchResult array from execute result. + * Used to safely extract tools from meta_search_tools response. + */ +function isMetaToolSearchResults(value: unknown): value is MetaToolSearchResult[] { + return ( + Array.isArray(value) && + value.every( + (item) => + typeof item === 'object' && + item !== null && + 'name' in item && + 'description' in item && + 'score' in item, + ) + ); +} + +/** Extract tools from search result with type safety */ +function getSearchResults(result: JsonObject): MetaToolSearchResult[] { + if (!isMetaToolSearchResults(result.tools)) { + throw new Error('Invalid tools response'); + } + return result.tools; +} import { type ExecuteConfig, type JSONSchema, @@ -747,7 +774,7 @@ describe('Meta Search Tools', () => { expect(result.tools).toBeDefined(); expect(Array.isArray(result.tools)).toBe(true); - const toolResults = result.tools as MetaToolSearchResult[]; + const toolResults = getSearchResults(result); const toolNames = toolResults.map((t) => t.name); expect(toolNames).toContain('bamboohr_create_employee'); @@ -763,7 +790,7 @@ describe('Meta Search Tools', () => { limit: 3, }); - const toolResults = result.tools as MetaToolSearchResult[]; + const toolResults = getSearchResults(result); const toolNames = toolResults.map((t) => t.name); expect(toolNames).toContain('bamboohr_create_time_off'); @@ -778,7 +805,7 @@ describe('Meta Search Tools', () => { limit: 2, }); - const toolResults = result.tools as MetaToolSearchResult[]; + const toolResults = getSearchResults(result); expect(toolResults.length).toBeLessThanOrEqual(2); }); @@ -791,7 +818,7 @@ describe('Meta Search Tools', () => { minScore: 0.8, }); - const toolResults = result.tools as MetaToolSearchResult[]; + const toolResults = getSearchResults(result); expect(toolResults.length).toBe(0); }); @@ -804,7 +831,7 @@ describe('Meta Search Tools', () => { limit: 1, }); - const toolResults = result.tools as MetaToolSearchResult[]; + const toolResults = getSearchResults(result); expect(toolResults.length).toBeGreaterThan(0); const firstTool = toolResults[0]; @@ -839,7 +866,7 @@ describe('Meta Search Tools', () => { }), ); - const toolResults = result.tools as MetaToolSearchResult[]; + const toolResults = getSearchResults(result); const toolNames = toolResults.map((t) => t.name); const hasCandidateTool = toolNames.some( @@ -938,7 +965,7 @@ describe('Meta Search Tools', () => { limit: 3, }); - const toolResults = searchResult.tools as MetaToolSearchResult[]; + const toolResults = getSearchResults(searchResult); expect(toolResults.length).toBeGreaterThan(0); // Find the create employee tool @@ -1026,7 +1053,7 @@ describe('Meta Search Tools - Hybrid Strategy', () => { expect(result.tools).toBeDefined(); expect(Array.isArray(result.tools)).toBe(true); - const toolResults = result.tools as MetaToolSearchResult[]; + const toolResults = getSearchResults(result); expect(toolResults.length).toBeGreaterThan(0); }); @@ -1041,7 +1068,7 @@ describe('Meta Search Tools - Hybrid Strategy', () => { limit: 3, }); - const toolResults = result.tools as MetaToolSearchResult[]; + const toolResults = getSearchResults(result); const toolNames = toolResults.map((t) => t.name); expect(toolNames).toContain('workday_create_candidate'); }); @@ -1057,7 +1084,7 @@ describe('Meta Search Tools - Hybrid Strategy', () => { limit: 10, }); - const toolResults = result.tools as MetaToolSearchResult[]; + const toolResults = getSearchResults(result); expect(toolResults.length).toBeGreaterThan(0); for (const tool of toolResults) { @@ -1077,7 +1104,7 @@ describe('Meta Search Tools - Hybrid Strategy', () => { limit: 3, }); - const toolResults = result.tools as MetaToolSearchResult[]; + const toolResults = getSearchResults(result); const toolNames = toolResults.map((t) => t.name); expect(toolNames).toContain('bamboohr_create_time_off'); }); From cd11a6d0499adb9b4cc8eac43ea38231c614ae38 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:51:25 +0000 Subject: [PATCH 5/5] docs(examples): update examples to use JsonObject type Update example files to work with the new JsonObject type: examples/meta-tools.ts: - Import JsonObject from @stackone/ai - Add explicit type annotation for intents array using `as const satisfies Array<{ intent: string; params: JsonObject }>` examples/tanstack-ai-integration.test.ts: - Simplify test structure and remove redundant assertions --- examples/meta-tools.ts | 18 +++++++++--------- examples/tanstack-ai-integration.test.ts | 19 ++++++------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/examples/meta-tools.ts b/examples/meta-tools.ts index 32377c6..42f964c 100644 --- a/examples/meta-tools.ts +++ b/examples/meta-tools.ts @@ -8,7 +8,7 @@ import process from 'node:process'; import { openai } from '@ai-sdk/openai'; -import { StackOneToolSet, Tools } from '@stackone/ai'; +import { type JsonObject, StackOneToolSet, Tools } from '@stackone/ai'; import { generateText, stepCountIs } from 'ai'; const apiKey = process.env.STACKONE_API_KEY; @@ -160,7 +160,7 @@ const directMetaToolUsage = async (): Promise => { try { // Prepare parameters based on the tool's schema - let params: Record = {}; + let params = {} satisfies JsonObject; if (firstTool.name === 'bamboohr_list_employees') { params = { limit: 5 }; } else if (firstTool.name === 'bamboohr_create_employee') { @@ -173,7 +173,7 @@ const directMetaToolUsage = async (): Promise => { const result = await executeTool.execute({ toolName: firstTool.name, - params: params, + params, }); console.log('Execution result:', JSON.stringify(result, null, 2)); @@ -209,7 +209,7 @@ const dynamicToolRouter = async (): Promise => { const metaTools = await combinedTools.metaTools(); // Create a router function that finds and executes tools based on intent - const routeAndExecute = async (intent: string, params: Record = {}) => { + const routeAndExecute = async (intent: string, params: JsonObject = {}) => { const filterTool = metaTools.getTool('meta_search_tools'); const executeTool = metaTools.getTool('meta_execute_tool'); if (!filterTool || !executeTool) throw new Error('Meta tools not found'); @@ -221,18 +221,18 @@ const dynamicToolRouter = async (): Promise => { minScore: 0.5, }); - const tools = searchResult.tools as Array<{ name: string; score: number }>; - if (tools.length === 0) { + const tools = searchResult.tools; + if (!Array.isArray(tools) || tools.length === 0) { return { error: 'No relevant tools found for the given intent' }; } - const selectedTool = tools[0]; + const selectedTool = tools[0] as { name: string; score: number }; console.log(`Routing to: ${selectedTool.name} (score: ${selectedTool.score.toFixed(2)})`); // Execute the selected tool return await executeTool.execute({ toolName: selectedTool.name, - params: params, + params, }); }; @@ -244,7 +244,7 @@ const dynamicToolRouter = async (): Promise => { params: { name: 'Jane Smith', email: 'jane@example.com' }, }, { intent: 'Find recruitment candidates', params: { status: 'active' } }, - ]; + ] as const satisfies { intent: string; params: JsonObject }[]; for (const { intent, params } of intents) { console.log(`\nIntent: "${intent}"`); diff --git a/examples/tanstack-ai-integration.test.ts b/examples/tanstack-ai-integration.test.ts index 17b1fe1..f8302e8 100644 --- a/examples/tanstack-ai-integration.test.ts +++ b/examples/tanstack-ai-integration.test.ts @@ -33,24 +33,19 @@ describe('tanstack-ai-integration example e2e', () => { // Get a specific tool const employeeTool = tools.getTool('bamboohr_get_employee'); - expect(employeeTool).toBeDefined(); + assert(employeeTool, 'Expected bamboohr_get_employee tool to exist'); // Create TanStack AI compatible tool wrapper // Use toJsonSchema() to get the parameter schema in JSON Schema format const getEmployeeTool = { - name: employeeTool!.name, - description: employeeTool!.description, - inputSchema: employeeTool!.toJsonSchema(), - execute: async (args: Record) => { - return employeeTool!.execute(args); - }, + name: employeeTool.name, + description: employeeTool.description, + inputSchema: employeeTool.toJsonSchema(), + execute: employeeTool.execute.bind(employeeTool), }; expect(getEmployeeTool.name).toBe('bamboohr_get_employee'); - expect(getEmployeeTool.description).toContain('employee'); - expect(getEmployeeTool.inputSchema).toBeDefined(); expect(getEmployeeTool.inputSchema.type).toBe('object'); - expect(typeof getEmployeeTool.execute).toBe('function'); }); it('should execute tool directly', async () => { @@ -68,9 +63,7 @@ describe('tanstack-ai-integration example e2e', () => { name: employeeTool.name, description: employeeTool.description, inputSchema: employeeTool.toJsonSchema(), - execute: async (args: Record) => { - return employeeTool.execute(args); - }, + execute: employeeTool.execute.bind(employeeTool), }; // Execute the tool directly to verify it works