From 9fca01ffd115ccd8f2b128c5a4d9e1cf1901c4e7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 13 Nov 2026 13:24:54 -0800 Subject: [PATCH] improvement(blocks): share the tool-call fallback helper and tidy the Hex/Stripe validation code --- apps/sim/blocks/blocks/azure_devops.test.ts | 14 +++-- apps/sim/blocks/blocks/azure_devops.ts | 22 ++++---- apps/sim/blocks/blocks/gmail.test.ts | 14 +++-- apps/sim/blocks/blocks/gmail.ts | 14 ++--- .../sim/blocks/blocks/google_calendar.test.ts | 15 +++--- apps/sim/blocks/blocks/google_calendar.ts | 6 +-- apps/sim/blocks/blocks/google_drive.test.ts | 54 +++++++------------ apps/sim/blocks/blocks/google_drive.ts | 16 +++--- apps/sim/blocks/blocks/google_groups.ts | 6 +-- apps/sim/blocks/blocks/google_vault.test.ts | 15 +++--- apps/sim/blocks/blocks/google_vault.ts | 7 +-- apps/sim/blocks/blocks/hex.test.ts | 26 ++++----- apps/sim/blocks/blocks/hex.ts | 25 ++++----- apps/sim/blocks/blocks/microsoft_ad.test.ts | 16 +++--- apps/sim/blocks/blocks/microsoft_ad.ts | 10 ++-- apps/sim/blocks/blocks/outlook.ts | 14 ++--- apps/sim/blocks/blocks/stripe.ts | 33 +++++------- apps/sim/blocks/utils.ts | 19 ++++++- apps/sim/tools/stripe/create_subscription.ts | 4 +- apps/sim/tools/stripe/list_charges.ts | 22 ++------ apps/sim/tools/stripe/list_customers.ts | 22 ++------ apps/sim/tools/stripe/list_events.ts | 22 ++------ apps/sim/tools/stripe/list_invoices.ts | 22 ++------ apps/sim/tools/stripe/list_payment_intents.ts | 22 ++------ apps/sim/tools/stripe/list_prices.ts | 22 ++------ apps/sim/tools/stripe/list_products.ts | 22 ++------ apps/sim/tools/stripe/list_subscriptions.ts | 22 ++------ apps/sim/tools/stripe/types.ts | 3 +- apps/sim/tools/stripe/update_subscription.ts | 3 +- apps/sim/tools/stripe/utils.ts | 35 ++++++++++++ 30 files changed, 210 insertions(+), 337 deletions(-) create mode 100644 apps/sim/tools/stripe/utils.ts diff --git a/apps/sim/blocks/blocks/azure_devops.test.ts b/apps/sim/blocks/blocks/azure_devops.test.ts index 51ea85ded52..df28ca4d0fc 100644 --- a/apps/sim/blocks/blocks/azure_devops.test.ts +++ b/apps/sim/blocks/blocks/azure_devops.test.ts @@ -1,13 +1,12 @@ import { describe, expect, it } from 'vitest' import { AzureDevOpsBlock } from '@/blocks/blocks/azure_devops' -import type { BlockConfig } from '@/blocks/types' +import type { BlockToolParamsContext } from '@/blocks/types' -/** The block's `tools.config.params` mapper, which every block under test defines. */ -function requireParamsMapper(block: BlockConfig) { - const mapper = block.tools.config?.params - if (!mapper) throw new Error(`${block.type} has no params mapper`) - return mapper -} +/** The block's `tools.config.params` mapper; `context` is set on a tool call, absent on the canvas. */ +const buildParams = ( + inputs: Record, + context?: BlockToolParamsContext +): Record => AzureDevOpsBlock.tools.config?.params?.(inputs, context) ?? {} /** * On an agent call the model supplies `continuationToken`/`top`/`iterationPath`/`format` under the @@ -15,7 +14,6 @@ function requireParamsMapper(block: BlockConfig) { * rendered in the agent tool input. The handler forwards `{ ...inputs, ...mapped }`. */ describe('AzureDevOpsBlock agent tool calls', () => { - const buildParams = requireParamsMapper(AzureDevOpsBlock) const agentCall = (operation: string, args: Record): Record => { const inputs = { ...args, operation, organization: 'org', project: 'proj' } return { ...inputs, ...buildParams(inputs, { operation }) } diff --git a/apps/sim/blocks/blocks/azure_devops.ts b/apps/sim/blocks/blocks/azure_devops.ts index ae89e9aa189..f4a0a56b46c 100644 --- a/apps/sim/blocks/blocks/azure_devops.ts +++ b/apps/sim/blocks/blocks/azure_devops.ts @@ -1,7 +1,7 @@ import { AzureIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { withSavedCredential } from '@/blocks/utils' +import { toolCallArg, withSavedCredential } from '@/blocks/utils' import type { AzureDevOpsBasicWorkItemType } from '@/tools/azure_devops/types' import { AZURE_DEVOPS_BASIC_WORK_ITEM_STATES } from '@/tools/azure_devops/utils' import { getTrigger } from '@/triggers' @@ -730,8 +730,8 @@ export const AzureDevOpsBlock: BlockConfig = { ...base, top: resolveTop(params.pipelinesTop, params.top), continuationToken: - ((params.pipelinesContinuationToken || - (context?.operation ? params.continuationToken : undefined)) as string) || + (params.pipelinesContinuationToken as string) || + toolCallArg(context, params.continuationToken) || undefined, } case 'azure_devops_get_pipeline': @@ -777,7 +777,7 @@ export const AzureDevOpsBlock: BlockConfig = { return { ...base, wiqlQuery: params.wiqlQuery as string, - top: resolveTop(params.queryTop, context?.operation ? params.top : undefined), + top: resolveTop(params.queryTop, toolCallArg(context, params.top)), } case 'azure_devops_get_work_item': return { ...base, workItemId: Number(params.workItemId) } @@ -818,8 +818,9 @@ export const AzureDevOpsBlock: BlockConfig = { description: (params.description as string) || undefined, areaPath: (params.areaPath as string) || undefined, iterationPath: - ((params.updateIterationPath || - (context?.operation ? params.iterationPath : undefined)) as string) || undefined, + (params.updateIterationPath as string) || + toolCallArg(context, params.iterationPath) || + undefined, tags: (params.tags as string) || undefined, } case 'azure_devops_add_comment': @@ -828,8 +829,9 @@ export const AzureDevOpsBlock: BlockConfig = { workItemId: Number(params.workItemId), text: params.commentText as string, format: - ((params.commentFormat || - (context?.operation ? params.format : undefined)) as string) || undefined, + (params.commentFormat as string) || + toolCallArg(context, params.format) || + undefined, } case 'azure_devops_get_comments': return { @@ -837,8 +839,8 @@ export const AzureDevOpsBlock: BlockConfig = { workItemId: Number(params.workItemId), top: resolveTop(params.commentsTop, params.top), continuationToken: - ((params.commentsContinuationToken || - (context?.operation ? params.continuationToken : undefined)) as string) || + (params.commentsContinuationToken as string) || + toolCallArg(context, params.continuationToken) || undefined, } default: diff --git a/apps/sim/blocks/blocks/gmail.test.ts b/apps/sim/blocks/blocks/gmail.test.ts index 615d4ae1f15..6f999b9c3e6 100644 --- a/apps/sim/blocks/blocks/gmail.test.ts +++ b/apps/sim/blocks/blocks/gmail.test.ts @@ -1,13 +1,12 @@ import { describe, expect, it } from 'vitest' import { GmailV2Block } from '@/blocks/blocks/gmail' -import type { BlockConfig } from '@/blocks/types' +import type { BlockToolParamsContext } from '@/blocks/types' -/** The block's `tools.config.params` mapper, which every block under test defines. */ -function requireParamsMapper(block: BlockConfig) { - const mapper = block.tools.config?.params - if (!mapper) throw new Error(`${block.type} has no params mapper`) - return mapper -} +/** The block's `tools.config.params` mapper; `context` is set on a tool call, absent on the canvas. */ +const buildParams = ( + inputs: Record, + context?: BlockToolParamsContext +): Record => GmailV2Block.tools.config?.params?.(inputs, context) ?? {} /** * On an agent call the model supplies `threadId`/`format`/`name`/`query`/`labelIds` under the @@ -15,7 +14,6 @@ function requireParamsMapper(block: BlockConfig) { * `listQuery`, `threadLabelIds`) are never rendered in the agent tool input. */ describe('GmailV2Block agent tool calls', () => { - const buildParams = requireParamsMapper(GmailV2Block) const agentCall = (operation: string, args: Record): Record => { const inputs = { ...args, operation } return { ...inputs, ...buildParams(inputs, { operation }) } diff --git a/apps/sim/blocks/blocks/gmail.ts b/apps/sim/blocks/blocks/gmail.ts index 02b13b64735..2f120055b2a 100644 --- a/apps/sim/blocks/blocks/gmail.ts +++ b/apps/sim/blocks/blocks/gmail.ts @@ -5,8 +5,10 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { createVersionedToolSelector, + isToolCall, normalizeFileInput, SERVICE_ACCOUNT_SUBBLOCKS, + toolCallArg, } from '@/blocks/utils' import { getTrigger } from '@/triggers' @@ -971,8 +973,6 @@ export const GmailV2Block: BlockConfig = { }), params: (params, context) => { const mapped = GmailBlock.tools?.config?.params?.(params, context) ?? params - /** Agent calls carry the model's tool-named args; on the canvas those keys may be stale. */ - const agentArg = (value: unknown) => (context?.operation ? value : undefined) const { labelName, targetThreadId, @@ -984,17 +984,17 @@ export const GmailV2Block: BlockConfig = { ...rest } = mapped if (LABEL_WRITE_OPERATIONS.includes(rest.operation)) { - rest.name = labelName || agentArg(rest.name) || undefined + rest.name = labelName || toolCallArg(context, rest.name) || undefined rest.messageListVisibility = messageListVisibility || undefined rest.labelListVisibility = labelListVisibility || undefined } if (THREAD_TARGET_OPERATIONS.includes(rest.operation)) { - rest.threadId = context?.operation ? targetThreadId || rest.threadId : targetThreadId - rest.format = threadFormat || agentArg(rest.format) || undefined + rest.threadId = isToolCall(context) ? targetThreadId || rest.threadId : targetThreadId + rest.format = threadFormat || toolCallArg(context, rest.format) || undefined } if (LIST_OPERATIONS.includes(rest.operation)) { - rest.query = listQuery || agentArg(rest.query) || undefined - const labelIds = threadLabelIds || agentArg(rest.labelIds) + rest.query = listQuery || toolCallArg(context, rest.query) || undefined + const labelIds = threadLabelIds || toolCallArg(context, rest.labelIds) rest.labelIds = labelIds ? String(labelIds).trim() : undefined } return rest diff --git a/apps/sim/blocks/blocks/google_calendar.test.ts b/apps/sim/blocks/blocks/google_calendar.test.ts index 033f526f7a8..07beac3b4d8 100644 --- a/apps/sim/blocks/blocks/google_calendar.test.ts +++ b/apps/sim/blocks/blocks/google_calendar.test.ts @@ -1,21 +1,18 @@ import { describe, expect, it } from 'vitest' import { GoogleCalendarBlock } from '@/blocks/blocks/google_calendar' -import type { BlockConfig } from '@/blocks/types' +import type { BlockToolParamsContext } from '@/blocks/types' -/** The block's `tools.config.params` mapper, which every block under test defines. */ -function requireParamsMapper(block: BlockConfig) { - const mapper = block.tools.config?.params - if (!mapper) throw new Error(`${block.type} has no params mapper`) - return mapper -} +/** The block's `tools.config.params` mapper; `context` is set on a tool call, absent on the canvas. */ +const buildParams = ( + inputs: Record, + context?: BlockToolParamsContext +): Record => GoogleCalendarBlock.tools.config?.params?.(inputs, context) ?? {} /** * On an agent call the model supplies `timeZone` under the tool's own name; the dedicated * `listTimeZone` field is never rendered in the agent tool input. */ describe('GoogleCalendarBlock list timeZone', () => { - const buildParams = requireParamsMapper(GoogleCalendarBlock) - it('keeps the model timeZone on an agent list call', () => { const inputs = { operation: 'list', timeZone: 'America/New_York' } const mapped = buildParams(inputs, { operation: 'list' }) diff --git a/apps/sim/blocks/blocks/google_calendar.ts b/apps/sim/blocks/blocks/google_calendar.ts index 806fe14f46b..a514ef29a54 100644 --- a/apps/sim/blocks/blocks/google_calendar.ts +++ b/apps/sim/blocks/blocks/google_calendar.ts @@ -2,7 +2,7 @@ import { GoogleCalendarIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { createVersionedToolSelector, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils' +import { createVersionedToolSelector, SERVICE_ACCOUNT_SUBBLOCKS, toolCallArg } from '@/blocks/utils' import { getTrigger } from '@/triggers' const CALENDAR_FIELD = ['calendarId', 'manualCalendarId'] as const @@ -1075,9 +1075,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, processedParams.timeZone = typeof listTimeZone === 'string' && listTimeZone.trim() ? listTimeZone.trim() - : context?.operation - ? processedParams.timeZone - : undefined + : toolCallArg(context, processedParams.timeZone) } return { diff --git a/apps/sim/blocks/blocks/google_drive.test.ts b/apps/sim/blocks/blocks/google_drive.test.ts index 4d475a3e1d2..51168d39875 100644 --- a/apps/sim/blocks/blocks/google_drive.test.ts +++ b/apps/sim/blocks/blocks/google_drive.test.ts @@ -1,17 +1,16 @@ import { triggersMock } from '@sim/testing/mocks/triggers.mock' import { describe, expect, it, vi } from 'vitest' -import type { BlockConfig } from '@/blocks/types' +import type { BlockToolParamsContext } from '@/blocks/types' vi.mock('@/triggers', () => triggersMock) import { GoogleDriveBlock } from '@/blocks/blocks/google_drive' -/** The block's `tools.config.params` mapper, which every block under test defines. */ -function requireParamsMapper(block: BlockConfig) { - const mapper = block.tools.config?.params - if (!mapper) throw new Error(`${block.type} has no params mapper`) - return mapper -} +/** The block's `tools.config.params` mapper; `context` is set on a tool call, absent on the canvas. */ +const buildParams = ( + inputs: Record, + context?: BlockToolParamsContext +): Record => GoogleDriveBlock.tools.config?.params?.(inputs, context) ?? {} const paginationCases = [ { operation: 'list', subBlockId: 'pageToken' }, @@ -22,8 +21,6 @@ const paginationCases = [ ] as const describe('GoogleDriveBlock pagination', () => { - const buildParams = requireParamsMapper(GoogleDriveBlock) - describe.each(paginationCases)('$operation', ({ operation, subBlockId }) => { /** * `pageToken` is the canonical tool param, so the `list` case would forward @@ -31,7 +28,7 @@ describe('GoogleDriveBlock pagination', () => { * ones the mapper has to translate, and none of them may survive as-is. */ it('forwards the page token to the tool under its own id', () => { - const params = buildParams({ operation, [subBlockId]: 'token-abc' }, undefined as never) + const params = buildParams({ operation, [subBlockId]: 'token-abc' }) expect(params).toMatchObject({ pageToken: 'token-abc' }) if (subBlockId !== 'pageToken') expect(params[subBlockId]).toBeUndefined() @@ -39,9 +36,7 @@ describe('GoogleDriveBlock pagination', () => { }) it('does not leak a page token into operations that do not paginate', () => { - expect( - buildParams({ operation: 'get_file', pageToken: 'token-abc' }, undefined as never).pageToken - ).toBeUndefined() + expect(buildParams({ operation: 'get_file', pageToken: 'token-abc' }).pageToken).toBeUndefined() }) /** @@ -54,7 +49,7 @@ describe('GoogleDriveBlock pagination', () => { '$subBlockId left over from a previous operation', ({ subBlockId }) => { it.each(['upload', 'get_file', 'list'])('is dropped under %s', (operation) => { - const params = buildParams({ operation, [subBlockId]: 'stale' }, undefined as never) + const params = buildParams({ operation, [subBlockId]: 'stale' }) expect(params.pageToken).toBeUndefined() expect(params[subBlockId]).toBeUndefined() @@ -63,15 +58,12 @@ describe('GoogleDriveBlock pagination', () => { ) it('prefers the operation-owned token when a stale sibling is also present', () => { - const params = buildParams( - { - operation: 'search', - searchPageToken: 'search-token', - commentsPageToken: 'stale', - pageToken: 'stale-canonical', - }, - undefined as never - ) + const params = buildParams({ + operation: 'search', + searchPageToken: 'search-token', + commentsPageToken: 'stale', + pageToken: 'stale-canonical', + }) expect(params.pageToken).toBe('search-token') expect(params.commentsPageToken).toBeUndefined() @@ -84,7 +76,6 @@ describe('GoogleDriveBlock pagination', () => { * the per-operation fields (`permissionsPageSize`, `searchPageToken`, `searchQuery`) are never rendered. */ describe('GoogleDriveBlock agent tool calls', () => { - const buildParams = requireParamsMapper(GoogleDriveBlock) const agentCall = (operation: string, args: Record): Record => { const inputs = { ...args, operation } return { ...inputs, ...buildParams(inputs, { operation }) } @@ -107,16 +98,9 @@ describe('GoogleDriveBlock agent tool calls', () => { expect(buildParams({ operation: 'search', pageToken: 'stale' }).pageToken).toBeUndefined() }) - it('drops a list page token left over on the canvas under a non-paginating operation', () => { - expect(buildParams({ operation: 'get_file', pageToken: 'stale' }).pageToken).toBeUndefined() - }) - /** A stored agent tool keeps its operation on the tool, not in the args the mapper receives. */ - it.each(['list', 'search', 'list_permissions', 'list_revisions', 'list_comments'])( - 'keeps the model pageToken on %s when the args carry no operation', - (operation) => { - const args = { fileId: 'f-1', pageToken: 'next' } - expect({ ...args, ...buildParams(args, { operation }) }.pageToken).toBe('next') - } - ) + it('keeps the model pageToken when the args carry no operation', () => { + const args = { fileId: 'f-1', pageToken: 'next' } + expect({ ...args, ...buildParams(args, { operation: 'list_comments' }) }.pageToken).toBe('next') + }) }) diff --git a/apps/sim/blocks/blocks/google_drive.ts b/apps/sim/blocks/blocks/google_drive.ts index 49d346bc529..18e709cff6d 100644 --- a/apps/sim/blocks/blocks/google_drive.ts +++ b/apps/sim/blocks/blocks/google_drive.ts @@ -3,7 +3,12 @@ import { GoogleDriveIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { normalizeFileInput, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils' +import { + isToolCall, + normalizeFileInput, + SERVICE_ACCOUNT_SUBBLOCKS, + toolCallArg, +} from '@/blocks/utils' import { getTrigger } from '@/triggers' /** @@ -1685,8 +1690,8 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.` includeDeleted === 'true' ? true : includeDeleted === 'false' ? false : undefined let effectivePageSize: string | undefined = pageSize - // Basic `pageSize`/`query` and advanced `pageToken` belong to List; only an agent call's value is trusted for other operations - const agentPageSize = context?.operation ? pageSize : undefined + // `pageSize`, `pageToken` and `query` are List's fields; other operations trust them only from a tool call + const agentPageSize = toolCallArg(context, pageSize) if (params.operation === 'search') effectivePageSize = searchPageSize || agentPageSize else if (params.operation === 'list_revisions') effectivePageSize = revisionsPageSize || agentPageSize @@ -1695,7 +1700,7 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.` else if (params.operation === 'list_permissions') effectivePageSize = permissionsPageSize || agentPageSize - const agentPageToken = context?.operation ? pageToken : undefined + const agentPageToken = toolCallArg(context, pageToken) let effectivePageToken: string | undefined = pageToken if (params.operation === 'search') effectivePageToken = searchPageToken || agentPageToken else if (params.operation === 'list_permissions') @@ -1704,12 +1709,11 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.` effectivePageToken = revisionsPageToken || agentPageToken else if (params.operation === 'list_comments') effectivePageToken = commentsPageToken || agentPageToken - // The canvas list token is stale for every other operation; an agent's own token is not else if (params.operation !== 'list') effectivePageToken = agentPageToken const effectiveQuery = params.operation === 'search' - ? context?.operation && !searchQuery + ? isToolCall(context) && !searchQuery ? query : searchQuery : query diff --git a/apps/sim/blocks/blocks/google_groups.ts b/apps/sim/blocks/blocks/google_groups.ts index a526b6dbef1..5948e35f745 100644 --- a/apps/sim/blocks/blocks/google_groups.ts +++ b/apps/sim/blocks/blocks/google_groups.ts @@ -2,7 +2,7 @@ import { GoogleGroupsIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils' +import { SERVICE_ACCOUNT_SUBBLOCKS, toolCallArg } from '@/blocks/utils' /** Settings the Update Settings operation forwards when set; empty means unchanged. */ const UPDATE_SETTINGS_FIELDS = [ @@ -739,9 +739,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, maxResults: rest.maxResults ? Number(rest.maxResults) : undefined, pageToken: rest.pageToken || undefined, userKey: rest.userKey || undefined, - orderBy: - (context?.operation ? rest.orderBy : undefined) || - (rest.sortOrder ? 'email' : undefined), + orderBy: toolCallArg(context, rest.orderBy) || (rest.sortOrder ? 'email' : undefined), sortOrder: rest.sortOrder || undefined, } case 'get_group': diff --git a/apps/sim/blocks/blocks/google_vault.test.ts b/apps/sim/blocks/blocks/google_vault.test.ts index 4bb1cc038c6..9cb2b713243 100644 --- a/apps/sim/blocks/blocks/google_vault.test.ts +++ b/apps/sim/blocks/blocks/google_vault.test.ts @@ -1,21 +1,18 @@ import { describe, expect, it } from 'vitest' import { GoogleVaultBlock } from '@/blocks/blocks/google_vault' -import type { BlockConfig } from '@/blocks/types' +import type { BlockToolParamsContext } from '@/blocks/types' -/** The block's `tools.config.params` mapper, which every block under test defines. */ -function requireParamsMapper(block: BlockConfig) { - const mapper = block.tools.config?.params - if (!mapper) throw new Error(`${block.type} has no params mapper`) - return mapper -} +/** The block's `tools.config.params` mapper; `context` is set on a tool call, absent on the canvas. */ +const buildParams = ( + inputs: Record, + context?: BlockToolParamsContext +): Record => GoogleVaultBlock.tools.config?.params?.(inputs, context) ?? {} /** * On an agent call the model supplies `includeSharedDrives` under the tool's own name; the * dedicated `exportIncludeSharedDrives` switch is never rendered in the agent tool input. */ describe('GoogleVaultBlock includeSharedDrives', () => { - const buildParams = requireParamsMapper(GoogleVaultBlock) - it.each(['create_matters_export', 'create_saved_query'])( 'keeps the model value on an agent %s call', (operation) => { diff --git a/apps/sim/blocks/blocks/google_vault.ts b/apps/sim/blocks/blocks/google_vault.ts index c35e7b7d576..a8823cb7f7c 100644 --- a/apps/sim/blocks/blocks/google_vault.ts +++ b/apps/sim/blocks/blocks/google_vault.ts @@ -3,7 +3,7 @@ import { GoogleVaultIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { parseOptionalBooleanInput, SERVICE_ACCOUNT_SUBBLOCKS } from '@/blocks/utils' +import { parseOptionalBooleanInput, SERVICE_ACCOUNT_SUBBLOCKS, toolCallArg } from '@/blocks/utils' export const GoogleVaultBlock: BlockConfig = { type: 'google_vault', @@ -958,8 +958,9 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`, ...(params.operation === 'list_matters' && matterState && { state: matterState }), ...(isQueryOperation && { includeSharedDrives: - context?.operation && exportIncludeSharedDrives == null - ? (parseOptionalBooleanInput(rest.includeSharedDrives) ?? false) + exportIncludeSharedDrives == null + ? (parseOptionalBooleanInput(toolCallArg(context, rest.includeSharedDrives)) ?? + false) : exportIncludeSharedDrives === true || exportIncludeSharedDrives === 'true', ...(dataScope && { dataScope }), }), diff --git a/apps/sim/blocks/blocks/hex.test.ts b/apps/sim/blocks/blocks/hex.test.ts index c6d09074339..5e632ff4f64 100644 --- a/apps/sim/blocks/blocks/hex.test.ts +++ b/apps/sim/blocks/blocks/hex.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it } from 'vitest' import { HexBlock } from '@/blocks/blocks/hex' +import type { BlockToolParamsContext } from '@/blocks/types' import { getProjectRunsTool } from '@/tools/hex/get_project_runs' import { getQueriedTablesTool } from '@/tools/hex/get_queried_tables' import { listGroupsTool } from '@/tools/hex/list_groups' @@ -13,8 +14,12 @@ import type { ToolConfig } from '@/tools/types' const PROJECT_ID = '11111111-1111-4111-8111-111111111111' -function requestUrl(tool: ToolConfig, inputs: Record): URL { - const mapped = HexBlock.tools.config?.params?.(inputs) ?? {} +function requestUrl( + tool: ToolConfig, + inputs: Record, + context?: BlockToolParamsContext +): URL { + const mapped = HexBlock.tools.config?.params?.(inputs, context) ?? {} const params = { apiKey: 'hex-token', ...inputs, ...mapped } const url = tool.request.url return new URL(typeof url === 'function' ? url(params) : url) @@ -73,21 +78,13 @@ describe('Hex block keeps stale subBlock values out of tool requests', () => { it('get project runs keeps a run status an agent passes directly', () => { const args = { operation: 'get_project_runs', projectId: PROJECT_ID, statusFilter: 'errored' } - const mapped = HexBlock.tools.config?.params?.(args, { operation: 'get_project_runs' }) ?? {} - const url = getProjectRunsTool.request.url - const built = new URL( - typeof url === 'function' ? url({ apiKey: 'hex-token', ...args, ...mapped }) : url - ) + const built = requestUrl(getProjectRunsTool, args, { operation: 'get_project_runs' }) expect(built.searchParams.get('statusFilter')).toBe('errored') }) it('list groups keeps a user id an agent passes directly', () => { const args = { operation: 'list_groups', userId: PROJECT_ID } - const mapped = HexBlock.tools.config?.params?.(args, { operation: 'list_groups' }) ?? {} - const url = listGroupsTool.request.url - const built = new URL( - typeof url === 'function' ? url({ apiKey: 'hex-token', ...args, ...mapped }) : url - ) + const built = requestUrl(listGroupsTool, args, { operation: 'list_groups' }) expect(built.searchParams.get('userId')).toBe(PROJECT_ID) }) @@ -98,10 +95,7 @@ describe('Hex block keeps stale subBlock values out of tool requests', () => { it('get queried tables keeps a cursor an agent passes directly', () => { const args = { operation: 'get_queried_tables', projectId: PROJECT_ID, after: 'tables-cursor' } - const mapped = HexBlock.tools.config?.params?.(args, { operation: 'get_queried_tables' }) ?? {} - const params = { apiKey: 'hex-token', ...args, ...mapped } - const url = getQueriedTablesTool.request.url - const built = new URL(typeof url === 'function' ? url(params) : url) + const built = requestUrl(getQueriedTablesTool, args, { operation: 'get_queried_tables' }) expect(built.searchParams.get('after')).toBe('tables-cursor') }) }) diff --git a/apps/sim/blocks/blocks/hex.ts b/apps/sim/blocks/blocks/hex.ts index 8622ab67150..8f4bcb9a5ab 100644 --- a/apps/sim/blocks/blocks/hex.ts +++ b/apps/sim/blocks/blocks/hex.ts @@ -1,7 +1,7 @@ import { HexIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { withSavedCredential } from '@/blocks/utils' +import { isToolCall, toolCallArg, withSavedCredential } from '@/blocks/utils' /** * Sort fields each list endpoint accepts. The only Sort By field is the List Projects one, so a @@ -25,8 +25,8 @@ const HEX_RUN_STATUSES: readonly string[] = [ ] function emptyToUndefined(value: unknown): unknown { - if (typeof value === 'string') return value.trim() === '' ? undefined : value.trim() - return value === null ? undefined : value + if (typeof value === 'string') return value.trim() || undefined + return value ?? undefined } export const HexBlock: BlockConfig = { @@ -712,8 +712,8 @@ Do not include any explanations, markdown formatting, or other text outside the params: (params, context) => { const result: Record = {} const op = params.operation - /** Only an agent tool call carries a context; its args arrive under the tool param names. */ - const isAgentCall = context?.operation !== undefined + const fieldOrToolArg = (field: unknown, arg: unknown) => + emptyToUndefined(field) ?? emptyToUndefined(toolCallArg(context, arg)) if (params.limit) result.limit = Number(params.limit) if (op === 'get_project_runs' && params.offset) result.offset = Number(params.offset) @@ -722,7 +722,7 @@ Do not include any explanations, markdown formatting, or other text outside the // An agent passes `statusFilter` directly; on the canvas it is the List Projects status. result.statusFilter = emptyToUndefined(params.runStatusFilter) ?? - (isAgentCall || HEX_RUN_STATUSES.includes(params.statusFilter) + (isToolCall(context) || HEX_RUN_STATUSES.includes(params.statusFilter) ? emptyToUndefined(params.statusFilter) : undefined) } @@ -732,17 +732,10 @@ Do not include any explanations, markdown formatting, or other text outside the } if (op === 'get_queried_tables') { // On the canvas the shared list cursors can hold another operation's stale value. - result.after = - emptyToUndefined(params.queriedTablesAfter) ?? - (isAgentCall ? emptyToUndefined(params.after) : undefined) - result.before = - emptyToUndefined(params.queriedTablesBefore) ?? - (isAgentCall ? emptyToUndefined(params.before) : undefined) + result.after = fieldOrToolArg(params.queriedTablesAfter, params.after) + result.before = fieldOrToolArg(params.queriedTablesBefore, params.before) } - if (op === 'list_groups') - result.userId = - emptyToUndefined(params.groupsUserId) ?? - (isAgentCall ? emptyToUndefined(params.userId) : undefined) + if (op === 'list_groups') result.userId = fieldOrToolArg(params.groupsUserId, params.userId) if ( (op === 'get_group' || op === 'update_group' || op === 'delete_group') && params.groupIdInput diff --git a/apps/sim/blocks/blocks/microsoft_ad.test.ts b/apps/sim/blocks/blocks/microsoft_ad.test.ts index f78e3b0fc93..12f343f31a8 100644 --- a/apps/sim/blocks/blocks/microsoft_ad.test.ts +++ b/apps/sim/blocks/blocks/microsoft_ad.test.ts @@ -1,13 +1,12 @@ import { describe, expect, it } from 'vitest' import { MicrosoftAdBlock } from '@/blocks/blocks/microsoft_ad' -import type { BlockConfig } from '@/blocks/types' +import type { BlockToolParamsContext } from '@/blocks/types' -/** The block's `tools.config.params` mapper, which every block under test defines. */ -function requireParamsMapper(block: BlockConfig) { - const mapper = block.tools.config?.params - if (!mapper) throw new Error(`${block.type} has no params mapper`) - return mapper -} +/** The block's `tools.config.params` mapper; `context` is set on a tool call, absent on the canvas. */ +const buildParams = ( + inputs: Record, + context?: BlockToolParamsContext +): Record => MicrosoftAdBlock.tools.config?.params?.(inputs, context) ?? {} /** * The tri-state assertions run against `{ ...inputs, ...buildParams(inputs) }`, the shape the @@ -16,8 +15,6 @@ function requireParamsMapper(block: BlockConfig) { * pass against the broken code. */ describe('MicrosoftAdBlock', () => { - const buildParams = requireParamsMapper(MicrosoftAdBlock) - const subBlock = (id: string) => MicrosoftAdBlock.subBlocks.find((candidate) => candidate.id === id)! @@ -87,7 +84,6 @@ describe('MicrosoftAdBlock', () => { * the per-operation fields (`signInFilter`, `deviceSearch`, `membershipScope`) are never rendered. */ describe('MicrosoftAdBlock agent tool calls', () => { - const buildParams = requireParamsMapper(MicrosoftAdBlock) const agentCall = (operation: string, args: Record): Record => { const inputs = { ...args, operation } return { ...inputs, ...buildParams(inputs, { operation }) } diff --git a/apps/sim/blocks/blocks/microsoft_ad.ts b/apps/sim/blocks/blocks/microsoft_ad.ts index 3c15a07d583..6e46d5e6bb6 100644 --- a/apps/sim/blocks/blocks/microsoft_ad.ts +++ b/apps/sim/blocks/blocks/microsoft_ad.ts @@ -2,7 +2,7 @@ import { AzureIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { parseOptionalBooleanInput } from '@/blocks/utils' +import { parseOptionalBooleanInput, toolCallArg } from '@/blocks/utils' /** Operations that act on a single user and therefore require the User ID field. */ const USER_ID_OPERATIONS = [ @@ -861,15 +861,13 @@ export const MicrosoftAdBlock: BlockConfig = { result.top = params.top && Number.isFinite(top) ? top : undefined if (params.nextLink) result.nextLink = params.nextLink const values = params as Record - // Agent calls carry the model's `filter`/`search`; on the canvas they may be stale advanced fields - const agentArg = (value: unknown) => (context?.operation ? value : undefined) result.filter = values[FILTER_FIELD_BY_OPERATION[params.operation]] || - agentArg(params.filter) || + toolCallArg(context, params.filter) || undefined result.search = values[SEARCH_FIELD_BY_OPERATION[params.operation]] || - agentArg(params.search) || + toolCallArg(context, params.search) || undefined if (params.operation === 'get_user') { result.includeManager = params.includeManager === true || params.includeManager === 'true' @@ -877,7 +875,7 @@ export const MicrosoftAdBlock: BlockConfig = { if (params.operation === 'list_user_memberships') { result.transitive = params.membershipScope ? params.membershipScope === 'transitive' - : (parseOptionalBooleanInput(agentArg(params.transitive)) ?? false) + : (parseOptionalBooleanInput(toolCallArg(context, params.transitive)) ?? false) } if (params.operation === 'set_password') { result.forceChangePasswordNextSignIn = params.forceChangePasswordNextSignIn !== 'false' diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index 127d4a47c33..01a6d4e49f9 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -2,7 +2,7 @@ import { OutlookIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { normalizeFileInput } from '@/blocks/utils' +import { normalizeFileInput, toolCallArg } from '@/blocks/utils' import { getTrigger } from '@/triggers' /** @@ -994,9 +994,6 @@ Return ONLY the comma-separated email list - no explanations, no extra text.`, // Agent calls may deliver booleans as the strings "true"/"false" const toBool = (value: unknown): boolean => value === true || value === 'true' - // Agent calls carry the model's tool-named arg; the canvas may hold a stale one from another operation - const agentArg = (value: unknown) => (context?.operation ? value : null) || null - const effectiveFolder = folder ? String(folder).trim() : '' const normalizedAttachments = normalizeFileInput(attachments) @@ -1013,13 +1010,16 @@ Return ONLY the comma-separated email list - no explanations, no extra text.`, } if (['send_outlook', 'draft_outlook'].includes(rest.operation)) { - rest.importance = mailImportance || agentArg(rest.importance) + rest.importance = mailImportance || toolCallArg(context, rest.importance) || null } // Assign explicitly so a cc/bcc/importance stored for another operation never reaches these tools if (['reply_outlook', 'reply_all_outlook', 'forward_outlook'].includes(rest.operation)) { - rest.cc = rest.operation === 'reply_all_outlook' ? null : replyCc || agentArg(rest.cc) - rest.bcc = replyBcc || agentArg(rest.bcc) + rest.cc = + rest.operation === 'reply_all_outlook' + ? null + : replyCc || toolCallArg(context, rest.cc) || null + rest.bcc = replyBcc || toolCallArg(context, rest.bcc) || null rest.importance = undefined } diff --git a/apps/sim/blocks/blocks/stripe.ts b/apps/sim/blocks/blocks/stripe.ts index 4b9993f3fea..48a57a62a0d 100644 --- a/apps/sim/blocks/blocks/stripe.ts +++ b/apps/sim/blocks/blocks/stripe.ts @@ -1,7 +1,7 @@ import { StripeIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { withSavedCredential } from '@/blocks/utils' +import { isToolCall, withSavedCredential } from '@/blocks/utils' import { getTrigger } from '@/triggers' /** @@ -11,9 +11,9 @@ import { getTrigger } from '@/triggers' const CAPTURE_AMOUNT_FIELD = ['amount_to_capture', 'amount'] as const /** - * Reads a Yes/No/Default dropdown. `''` is the "Default" option and is never - * sent, so Stripe applies its own default. `null` keeps its historical meaning - * (`false`) for saved blocks that stored no value, so those requests are unchanged. + * Reads a Yes/No/Default dropdown. Default (`''`) and an absent value are never sent, so Stripe + * applies its own default; `null`, which saved blocks hold when they stored no value, reads as + * `false`. */ function parseOptionalBoolean(value: unknown): boolean | undefined { if (value === undefined || value === '') return undefined @@ -968,11 +968,8 @@ export const StripeBlock: BlockConfig = { throw new Error(`Invalid JSON input: ${error.message}`) } - /** - * Every key is assigned, unset ones as `undefined`: the executor merges - * these over the raw inputs, so an omitted key would let the raw `''` - * through to the tool. - */ + // Every key is assigned, unset ones as `undefined`: the executor merges these over the raw + // inputs, so an omitted key would let the raw `''` through to the tool const parsedBooleans: Record = { cancel_at_period_end: parseOptionalBoolean(cancel_at_period_end), auto_advance: parseOptionalBoolean(auto_advance), @@ -983,21 +980,17 @@ export const StripeBlock: BlockConfig = { paid_out_of_band: parseOptionalBoolean(paid_out_of_band), } - /** - * An agent tool call (the only caller passing a context) sends cursors under the tool param - * names. On the canvas the subscription cursor fields can hold a value stored for another - * operation, so the other list and search operations read their own fields. - */ - const isAgentCall = context?.operation !== undefined - const op = context?.operation ?? operation - const pagination = isAgentCall + // A tool call sends cursors under the tool param names. On the canvas the subscription + // cursor fields can hold a value stored for another operation, so the other list and + // search operations read their own fields + const pagination = isToolCall(context) ? { starting_after, ending_before, page } : { starting_after: - op === SUBSCRIPTION_LIST_OPERATION ? starting_after : list_starting_after, + operation === SUBSCRIPTION_LIST_OPERATION ? starting_after : list_starting_after, ending_before: - op === SUBSCRIPTION_LIST_OPERATION ? ending_before : list_ending_before, - page: op === SUBSCRIPTION_SEARCH_OPERATION ? page : search_page, + operation === SUBSCRIPTION_LIST_OPERATION ? ending_before : list_ending_before, + page: operation === SUBSCRIPTION_SEARCH_OPERATION ? page : search_page, } return { diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 68cbe8cbf92..ceffb5ed61d 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -8,7 +8,7 @@ import type { SelectorKey } from '@/lib/selectors/manifest' import { containsReference } from '@/lib/workflows/sanitization/references' import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies' import { isTriggerModeSubBlock } from '@/lib/workflows/subblocks/visibility' -import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import type { BlockConfig, BlockToolParamsContext, SubBlockConfig } from '@/blocks/types' import { findProviderFromModel, getBaseModelProviders, @@ -931,6 +931,23 @@ export function parseOptionalNumberInput( return parsed } +/** + * Whether a block's `tools.config.params` mapper is running for a tool call (agent, Pi, Human v2), + * whose model-supplied args arrive under the tool's own param names. The canvas calls the mapper + * with no context, where those raw keys can hold a value stored for another operation. + */ +export function isToolCall(context: BlockToolParamsContext | undefined): boolean { + return Boolean(context?.operation) +} + +/** `value` on a tool call, `undefined` on the canvas (see {@link isToolCall}). */ +export function toolCallArg( + context: BlockToolParamsContext | undefined, + value: T +): T | undefined { + return isToolCall(context) ? value : undefined +} + /** * Parses an optional boolean block input value. * Returns `undefined` for empty or unrecognized values. diff --git a/apps/sim/tools/stripe/create_subscription.ts b/apps/sim/tools/stripe/create_subscription.ts index f840d52577e..e3e80f04dd0 100644 --- a/apps/sim/tools/stripe/create_subscription.ts +++ b/apps/sim/tools/stripe/create_subscription.ts @@ -99,8 +99,8 @@ export const stripeCreateSubscriptionTool: ToolConfig< } const trialPeriodDays: unknown = params.trial_period_days - if (trialPeriodDays !== undefined && trialPeriodDays !== null && trialPeriodDays !== '') { - formData.append('trial_period_days', Number(params.trial_period_days).toString()) + if (trialPeriodDays != null && trialPeriodDays !== '') { + formData.append('trial_period_days', Number(trialPeriodDays).toString()) } if (params.default_payment_method) { formData.append('default_payment_method', params.default_payment_method) diff --git a/apps/sim/tools/stripe/list_charges.ts b/apps/sim/tools/stripe/list_charges.ts index 1d285c7378b..342301647ec 100644 --- a/apps/sim/tools/stripe/list_charges.ts +++ b/apps/sim/tools/stripe/list_charges.ts @@ -1,5 +1,6 @@ import { STRIPE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ChargeListResponse, ListChargesParams } from '@/tools/stripe/types' +import { appendListCursors, listCursorParams } from '@/tools/stripe/utils' import type { ToolConfig } from '@/tools/types' export const stripeListChargesTool: ToolConfig = { @@ -34,31 +35,14 @@ export const stripeListChargesTool: ToolConfig { - if (params.starting_after && params.ending_before) { - throw new Error('Provide either starting_after or ending_before, not both') - } const url = new URL('https://api.stripe.com/v1/charges') if (params.limit) url.searchParams.append('limit', params.limit.toString()) - if (params.starting_after) url.searchParams.append('starting_after', params.starting_after) - if (params.ending_before) url.searchParams.append('ending_before', params.ending_before) + appendListCursors(url, params) if (params.customer) url.searchParams.append('customer', params.customer) if (params.created) { Object.entries(params.created).forEach(([key, value]) => { diff --git a/apps/sim/tools/stripe/list_customers.ts b/apps/sim/tools/stripe/list_customers.ts index 131237bc214..83a45e68ffb 100644 --- a/apps/sim/tools/stripe/list_customers.ts +++ b/apps/sim/tools/stripe/list_customers.ts @@ -1,6 +1,7 @@ import { STRIPE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { CustomerListResponse, ListCustomersParams } from '@/tools/stripe/types' import { CUSTOMER_OUTPUT, LIST_METADATA_OUTPUT_PROPERTIES } from '@/tools/stripe/types' +import { appendListCursors, listCursorParams } from '@/tools/stripe/utils' import type { ToolConfig } from '@/tools/types' export const stripeListCustomersTool: ToolConfig = { @@ -35,31 +36,14 @@ export const stripeListCustomersTool: ToolConfig { - if (params.starting_after && params.ending_before) { - throw new Error('Provide either starting_after or ending_before, not both') - } const url = new URL('https://api.stripe.com/v1/customers') if (params.limit) url.searchParams.append('limit', params.limit.toString()) - if (params.starting_after) url.searchParams.append('starting_after', params.starting_after) - if (params.ending_before) url.searchParams.append('ending_before', params.ending_before) + appendListCursors(url, params) if (params.email) url.searchParams.append('email', params.email) if (params.created) { Object.entries(params.created).forEach(([key, value]) => { diff --git a/apps/sim/tools/stripe/list_events.ts b/apps/sim/tools/stripe/list_events.ts index a70950d7b02..d9374f056a9 100644 --- a/apps/sim/tools/stripe/list_events.ts +++ b/apps/sim/tools/stripe/list_events.ts @@ -1,5 +1,6 @@ import { STRIPE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { EventListResponse, ListEventsParams } from '@/tools/stripe/types' +import { appendListCursors, listCursorParams } from '@/tools/stripe/utils' import type { ToolConfig } from '@/tools/types' export const stripeListEventsTool: ToolConfig = { @@ -34,31 +35,14 @@ export const stripeListEventsTool: ToolConfig { - if (params.starting_after && params.ending_before) { - throw new Error('Provide either starting_after or ending_before, not both') - } const url = new URL('https://api.stripe.com/v1/events') if (params.limit) url.searchParams.append('limit', params.limit.toString()) - if (params.starting_after) url.searchParams.append('starting_after', params.starting_after) - if (params.ending_before) url.searchParams.append('ending_before', params.ending_before) + appendListCursors(url, params) if (params.type) url.searchParams.append('type', params.type) if (params.created) { Object.entries(params.created).forEach(([key, value]) => { diff --git a/apps/sim/tools/stripe/list_invoices.ts b/apps/sim/tools/stripe/list_invoices.ts index b6516427a91..43a5cf8aaa5 100644 --- a/apps/sim/tools/stripe/list_invoices.ts +++ b/apps/sim/tools/stripe/list_invoices.ts @@ -1,5 +1,6 @@ import { STRIPE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { InvoiceListResponse, ListInvoicesParams } from '@/tools/stripe/types' +import { appendListCursors, listCursorParams } from '@/tools/stripe/utils' import type { ToolConfig } from '@/tools/types' export const stripeListInvoicesTool: ToolConfig = { @@ -34,31 +35,14 @@ export const stripeListInvoicesTool: ToolConfig { - if (params.starting_after && params.ending_before) { - throw new Error('Provide either starting_after or ending_before, not both') - } const url = new URL('https://api.stripe.com/v1/invoices') if (params.limit) url.searchParams.append('limit', params.limit.toString()) - if (params.starting_after) url.searchParams.append('starting_after', params.starting_after) - if (params.ending_before) url.searchParams.append('ending_before', params.ending_before) + appendListCursors(url, params) if (params.customer) url.searchParams.append('customer', params.customer) if (params.status) url.searchParams.append('status', params.status) return url.toString() diff --git a/apps/sim/tools/stripe/list_payment_intents.ts b/apps/sim/tools/stripe/list_payment_intents.ts index 5ce3034fe21..c5f515a7945 100644 --- a/apps/sim/tools/stripe/list_payment_intents.ts +++ b/apps/sim/tools/stripe/list_payment_intents.ts @@ -1,6 +1,7 @@ import { STRIPE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ListPaymentIntentsParams, PaymentIntentListResponse } from '@/tools/stripe/types' import { LIST_METADATA_OUTPUT_PROPERTIES, PAYMENT_INTENT_OUTPUT } from '@/tools/stripe/types' +import { appendListCursors, listCursorParams } from '@/tools/stripe/utils' import type { ToolConfig } from '@/tools/types' export const stripeListPaymentIntentsTool: ToolConfig< @@ -38,31 +39,14 @@ export const stripeListPaymentIntentsTool: ToolConfig< visibility: 'user-or-llm', description: 'Filter by creation date (e.g., {"gt": 1633024800})', }, - starting_after: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: - 'Next-page cursor: last Payment Intent ID from the previous page. Do not combine with ending_before.', - }, - ending_before: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: - 'Previous-page cursor: first Payment Intent ID from the current page. Do not combine with starting_after.', - }, + ...listCursorParams('Payment Intent'), }, request: { url: (params) => { - if (params.starting_after && params.ending_before) { - throw new Error('Provide either starting_after or ending_before, not both') - } const url = new URL('https://api.stripe.com/v1/payment_intents') if (params.limit) url.searchParams.append('limit', params.limit.toString()) - if (params.starting_after) url.searchParams.append('starting_after', params.starting_after) - if (params.ending_before) url.searchParams.append('ending_before', params.ending_before) + appendListCursors(url, params) if (params.customer) url.searchParams.append('customer', params.customer) if (params.created) { Object.entries(params.created).forEach(([key, value]) => { diff --git a/apps/sim/tools/stripe/list_prices.ts b/apps/sim/tools/stripe/list_prices.ts index 8d5fb900ca0..25ba9c07f88 100644 --- a/apps/sim/tools/stripe/list_prices.ts +++ b/apps/sim/tools/stripe/list_prices.ts @@ -1,5 +1,6 @@ import { STRIPE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ListPricesParams, PriceListResponse } from '@/tools/stripe/types' +import { appendListCursors, listCursorParams } from '@/tools/stripe/utils' import type { ToolConfig } from '@/tools/types' export const stripeListPricesTool: ToolConfig = { @@ -34,31 +35,14 @@ export const stripeListPricesTool: ToolConfig { - if (params.starting_after && params.ending_before) { - throw new Error('Provide either starting_after or ending_before, not both') - } const url = new URL('https://api.stripe.com/v1/prices') if (params.limit) url.searchParams.append('limit', params.limit.toString()) - if (params.starting_after) url.searchParams.append('starting_after', params.starting_after) - if (params.ending_before) url.searchParams.append('ending_before', params.ending_before) + appendListCursors(url, params) if (params.product) url.searchParams.append('product', params.product) if (params.active !== undefined) url.searchParams.append('active', params.active.toString()) return url.toString() diff --git a/apps/sim/tools/stripe/list_products.ts b/apps/sim/tools/stripe/list_products.ts index 8f7b0654caf..44b4da86dfe 100644 --- a/apps/sim/tools/stripe/list_products.ts +++ b/apps/sim/tools/stripe/list_products.ts @@ -1,5 +1,6 @@ import { STRIPE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ListProductsParams, ProductListResponse } from '@/tools/stripe/types' +import { appendListCursors, listCursorParams } from '@/tools/stripe/utils' import type { ToolConfig } from '@/tools/types' export const stripeListProductsTool: ToolConfig = { @@ -28,31 +29,14 @@ export const stripeListProductsTool: ToolConfig { - if (params.starting_after && params.ending_before) { - throw new Error('Provide either starting_after or ending_before, not both') - } const url = new URL('https://api.stripe.com/v1/products') if (params.limit) url.searchParams.append('limit', params.limit.toString()) - if (params.starting_after) url.searchParams.append('starting_after', params.starting_after) - if (params.ending_before) url.searchParams.append('ending_before', params.ending_before) + appendListCursors(url, params) if (params.active !== undefined) url.searchParams.append('active', String(params.active)) return url.toString() }, diff --git a/apps/sim/tools/stripe/list_subscriptions.ts b/apps/sim/tools/stripe/list_subscriptions.ts index 61837582799..ff4fa3f13ee 100644 --- a/apps/sim/tools/stripe/list_subscriptions.ts +++ b/apps/sim/tools/stripe/list_subscriptions.ts @@ -1,6 +1,7 @@ import { STRIPE_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ListSubscriptionsParams, SubscriptionListResponse } from '@/tools/stripe/types' import { LIST_METADATA_OUTPUT_PROPERTIES, SUBSCRIPTION_OUTPUT } from '@/tools/stripe/types' +import { appendListCursors, listCursorParams } from '@/tools/stripe/utils' import type { ToolConfig } from '@/tools/types' export const stripeListSubscriptionsTool: ToolConfig< @@ -40,20 +41,7 @@ export const stripeListSubscriptionsTool: ToolConfig< description: 'Filter by status (active, past_due, unpaid, canceled, incomplete, incomplete_expired, trialing, all)', }, - starting_after: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: - 'Next-page cursor: last subscription ID from the previous page. Do not combine with ending_before.', - }, - ending_before: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: - 'Previous-page cursor: first subscription ID from the current page. Do not combine with starting_after.', - }, + ...listCursorParams('subscription'), price: { type: 'string', required: false, @@ -64,16 +52,12 @@ export const stripeListSubscriptionsTool: ToolConfig< request: { url: (params) => { - if (params.starting_after && params.ending_before) { - throw new Error('Provide either starting_after or ending_before, not both') - } const url = new URL('https://api.stripe.com/v1/subscriptions') if (params.limit) url.searchParams.append('limit', params.limit.toString()) if (params.customer) url.searchParams.append('customer', params.customer) if (params.status) url.searchParams.append('status', params.status) if (params.price) url.searchParams.append('price', params.price) - if (params.starting_after) url.searchParams.append('starting_after', params.starting_after) - if (params.ending_before) url.searchParams.append('ending_before', params.ending_before) + appendListCursors(url, params) return url.toString() }, method: 'GET', diff --git a/apps/sim/tools/stripe/types.ts b/apps/sim/tools/stripe/types.ts index 8f3c4bae808..489daa9ed6d 100644 --- a/apps/sim/tools/stripe/types.ts +++ b/apps/sim/tools/stripe/types.ts @@ -1191,8 +1191,7 @@ export const LIST_METADATA_OUTPUT_PROPERTIES = { * search endpoints return instead of accepting `starting_after`. */ export const SEARCH_METADATA_OUTPUT_PROPERTIES = { - count: { type: 'number', description: 'Number of items returned' }, - has_more: { type: 'boolean', description: 'Whether more items exist beyond this page' }, + ...LIST_METADATA_OUTPUT_PROPERTIES, next_page: { type: 'string', nullable: true, diff --git a/apps/sim/tools/stripe/update_subscription.ts b/apps/sim/tools/stripe/update_subscription.ts index 342ff6f830b..15f73bd3afb 100644 --- a/apps/sim/tools/stripe/update_subscription.ts +++ b/apps/sim/tools/stripe/update_subscription.ts @@ -85,8 +85,7 @@ export const stripeUpdateSubscriptionTool: ToolConfig< if (item.quantity != null && String(item.quantity).trim() !== '') { formData.append(`items[${index}][quantity]`, String(item.quantity)) } - if (item.deleted === true || String(item.deleted) === 'true') - formData.append(`items[${index}][deleted]`, 'true') + if (String(item.deleted) === 'true') formData.append(`items[${index}][deleted]`, 'true') }) } diff --git a/apps/sim/tools/stripe/utils.ts b/apps/sim/tools/stripe/utils.ts new file mode 100644 index 00000000000..3c286e42fd0 --- /dev/null +++ b/apps/sim/tools/stripe/utils.ts @@ -0,0 +1,35 @@ +import type { ToolConfig } from '@/tools/types' + +interface StripeListCursors { + starting_after?: string + ending_before?: string +} + +/** `starting_after`/`ending_before` param definitions for a Stripe list tool over `objectName`. */ +export function listCursorParams( + objectName: string +): Record { + return { + starting_after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: `Next-page cursor: last ${objectName} ID from the previous page. Do not combine with ending_before.`, + }, + ending_before: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: `Previous-page cursor: first ${objectName} ID from the current page. Do not combine with starting_after.`, + }, + } +} + +/** Appends a list tool's cursors to `url`; Stripe rejects a request that sets both. */ +export function appendListCursors(url: URL, params: StripeListCursors): void { + if (params.starting_after && params.ending_before) { + throw new Error('Provide either starting_after or ending_before, not both') + } + if (params.starting_after) url.searchParams.append('starting_after', params.starting_after) + if (params.ending_before) url.searchParams.append('ending_before', params.ending_before) +}