From 083a140f4e29430b8b799dafc4410ddc053d4831 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 21:31:46 -0700 Subject: [PATCH 1/4] fix(ashby): parse alternateEmailAddresses and socialLinks into arrays before dispatch The Ashby create_candidate and update_candidate tools require alternateEmailAddresses (string[]) and socialLinks ({type,url}[]) as JSON arrays in the request body, guarded by Array.isArray checks. The block collected both through long-input text fields but forwarded the raw string straight through to tools.config.params, so Array.isArray was always false and both fields were silently dropped on every create/update call. Parse them in tools.config.params (execution-time, after variable resolution) using the same comma-separated-or-JSON-array pattern used elsewhere in the codebase (see blocks/findymail.ts), and add wandConfig to both fields so the AI wand can generate well-formed input for them. --- apps/sim/blocks/blocks/ashby.test.ts | 57 ++++++++++++++++++++++++++++ apps/sim/blocks/blocks/ashby.ts | 53 +++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 apps/sim/blocks/blocks/ashby.test.ts diff --git a/apps/sim/blocks/blocks/ashby.test.ts b/apps/sim/blocks/blocks/ashby.test.ts new file mode 100644 index 00000000000..a095a2c16b3 --- /dev/null +++ b/apps/sim/blocks/blocks/ashby.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { AshbyBlock } from './ashby' + +describe('AshbyBlock', () => { + const buildParams = (operation: string, extra: Record) => ({ + operation, + ...extra, + }) + + describe('alternateEmailAddresses parsing (create_candidate)', () => { + it('parses a comma-separated string into an array', () => { + const result = AshbyBlock.tools.config.params!( + buildParams('create_candidate', { + alternateEmailAddresses: 'a@x.com, b@x.com', + }) + ) + expect(result.alternateEmailAddresses).toEqual(['a@x.com', 'b@x.com']) + }) + + it('parses a JSON array string into an array', () => { + const result = AshbyBlock.tools.config.params!( + buildParams('create_candidate', { + alternateEmailAddresses: '["a@x.com","b@x.com"]', + }) + ) + expect(result.alternateEmailAddresses).toEqual(['a@x.com', 'b@x.com']) + }) + + it('omits the field entirely when empty', () => { + const result = AshbyBlock.tools.config.params!( + buildParams('create_candidate', { alternateEmailAddresses: '' }) + ) + expect(result.alternateEmailAddresses).toBeUndefined() + }) + }) + + describe('socialLinks parsing (update_candidate)', () => { + it('parses a JSON array of link objects', () => { + const result = AshbyBlock.tools.config.params!( + buildParams('update_candidate', { + socialLinks: '[{"type":"Twitter","url":"https://twitter.com/jane"}]', + }) + ) + expect(result.socialLinks).toEqual([{ type: 'Twitter', url: 'https://twitter.com/jane' }]) + }) + + it('omits the field when the JSON is malformed', () => { + const result = AshbyBlock.tools.config.params!( + buildParams('update_candidate', { socialLinks: 'not json' }) + ) + expect(result.socialLinks).toBeUndefined() + }) + }) +}) diff --git a/apps/sim/blocks/blocks/ashby.ts b/apps/sim/blocks/blocks/ashby.ts index 60a4cbe2aad..1fae6775cf6 100644 --- a/apps/sim/blocks/blocks/ashby.ts +++ b/apps/sim/blocks/blocks/ashby.ts @@ -2,6 +2,34 @@ import { AshbyIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' import { getTrigger } from '@/triggers' +function parseStringListInput(value: unknown): string[] { + if (Array.isArray(value)) return value.map(String) + if (typeof value !== 'string') return [] + const trimmed = value.trim() + if (!trimmed) return [] + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed) + if (Array.isArray(parsed)) return parsed.map(String) + } catch {} + } + return trimmed + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +} + +function parseSocialLinksInput(value: unknown): Array<{ type: string; url: string }> { + if (Array.isArray(value)) return value as Array<{ type: string; url: string }> + if (typeof value !== 'string' || !value.trim()) return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + export const AshbyBlock: BlockConfig = { type: 'ashby', name: 'Ashby', @@ -563,6 +591,14 @@ Output only the ISO 8601 timestamp string, nothing else.`, placeholder: 'Comma-separated or JSON array (e.g. ["a@x.com","b@x.com"])', condition: { field: 'operation', value: 'create_candidate' }, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated or JSON array of email addresses based on the user's description. +Examples: +- "her work and personal emails" -> ["work@company.com","personal@example.com"] +Output only the list, nothing else.`, + generationType: 'json-object', + }, }, { id: 'socialLinks', @@ -571,6 +607,14 @@ Output only the ISO 8601 timestamp string, nothing else.`, placeholder: 'JSON array (e.g. [{"type":"Twitter","url":"https://twitter.com/x"}])', condition: { field: 'operation', value: 'update_candidate' }, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a JSON array of social link objects ({"type","url"}) based on the user's description. +Examples: +- "his Twitter is @jane and portfolio is jane.dev" -> [{"type":"Twitter","url":"https://twitter.com/jane"},{"type":"Portfolio","url":"https://jane.dev"}] +Output only the JSON array, nothing else.`, + generationType: 'json-object', + }, }, { id: 'includeArchived', @@ -772,9 +816,14 @@ Output only the ISO 8601 timestamp string, nothing else.`, result.applicationId = params.offerApplicationId } if (params.alternateEmailAddresses) { - result.alternateEmailAddresses = params.alternateEmailAddresses + const alternateEmailAddresses = parseStringListInput(params.alternateEmailAddresses) + if (alternateEmailAddresses.length > 0) + result.alternateEmailAddresses = alternateEmailAddresses + } + if (params.socialLinks) { + const socialLinks = parseSocialLinksInput(params.socialLinks) + if (socialLinks.length > 0) result.socialLinks = socialLinks } - if (params.socialLinks) result.socialLinks = params.socialLinks return result }, }, From 92cfb950c168b0d74e64559807d25b94666357b4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 21:41:20 -0700 Subject: [PATCH 2/4] fix(ashby): drop json-object generationType from array-shaped wandConfig fields generationType: 'json-object' makes the wand API append an instruction that the response must start with { and end with }, but alternateEmailAddresses/socialLinks parse a raw JSON array or comma-separated string, not an object. A wand-generated {"emails":[...]}-shaped response would get comma-split into invalid email fragments by parseStringListInput, and an object-wrapped socialLinks response would get silently dropped by parseSocialLinksInput returning []. Matches the existing array-field wandConfig pattern in blocks/findymail.ts, which never sets generationType and relies on the prompt text alone. --- apps/sim/blocks/blocks/ashby.test.ts | 14 ++++++++++++++ apps/sim/blocks/blocks/ashby.ts | 2 -- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/sim/blocks/blocks/ashby.test.ts b/apps/sim/blocks/blocks/ashby.test.ts index a095a2c16b3..91008b892e5 100644 --- a/apps/sim/blocks/blocks/ashby.test.ts +++ b/apps/sim/blocks/blocks/ashby.test.ts @@ -54,4 +54,18 @@ describe('AshbyBlock', () => { expect(result.socialLinks).toBeUndefined() }) }) + + describe('wandConfig on array-shaped fields', () => { + it('does not request object-wrapped output for alternateEmailAddresses or socialLinks', () => { + // generationType 'json-object' makes the wand API append "the response + // must start with { and end with }", which conflicts with these fields' + // array-or-comma-separated parsers (parseStringListInput/parseSocialLinksInput). + const alternateEmailAddresses = AshbyBlock.subBlocks.find( + (s) => s.id === 'alternateEmailAddresses' + ) + const socialLinks = AshbyBlock.subBlocks.find((s) => s.id === 'socialLinks') + expect(alternateEmailAddresses?.wandConfig?.generationType).not.toBe('json-object') + expect(socialLinks?.wandConfig?.generationType).not.toBe('json-object') + }) + }) }) diff --git a/apps/sim/blocks/blocks/ashby.ts b/apps/sim/blocks/blocks/ashby.ts index 1fae6775cf6..757842f6ffa 100644 --- a/apps/sim/blocks/blocks/ashby.ts +++ b/apps/sim/blocks/blocks/ashby.ts @@ -597,7 +597,6 @@ Output only the ISO 8601 timestamp string, nothing else.`, Examples: - "her work and personal emails" -> ["work@company.com","personal@example.com"] Output only the list, nothing else.`, - generationType: 'json-object', }, }, { @@ -613,7 +612,6 @@ Output only the list, nothing else.`, Examples: - "his Twitter is @jane and portfolio is jane.dev" -> [{"type":"Twitter","url":"https://twitter.com/jane"},{"type":"Portfolio","url":"https://jane.dev"}] Output only the JSON array, nothing else.`, - generationType: 'json-object', }, }, { From 7f704583ac24247cf02463fb4ff4e9576ab9c483 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 21:46:19 -0700 Subject: [PATCH 3/4] fix(ashby): remove the non-functional candidateId filter from list_applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-verified against Ashby's application.list endpoint: passing candidateId (including a nonexistent UUID) returns identical, unfiltered results either way — Ashby's API silently ignores this body field entirely. Sending it gave users the false impression of filtering by candidate while actually returning every application. Removed the param, the tool type, and the block's filterCandidateId subBlock/wiring/input. The correct path for a candidate's applications is ashby_get_candidate's applicationIds field. --- apps/sim/blocks/blocks/ashby.test.ts | 14 ++++++++++++++ apps/sim/blocks/blocks/ashby.ts | 12 ------------ apps/sim/tools/ashby/list_applications.ts | 7 ------- apps/sim/tools/ashby/types.ts | 1 - 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/apps/sim/blocks/blocks/ashby.test.ts b/apps/sim/blocks/blocks/ashby.test.ts index 91008b892e5..75a26b13b68 100644 --- a/apps/sim/blocks/blocks/ashby.test.ts +++ b/apps/sim/blocks/blocks/ashby.test.ts @@ -68,4 +68,18 @@ describe('AshbyBlock', () => { expect(socialLinks?.wandConfig?.generationType).not.toBe('json-object') }) }) + + describe('list_applications candidateId filter', () => { + it('has no filterCandidateId subBlock or wiring', () => { + // Ashby's application.list endpoint silently ignores a candidateId + // body field (confirmed live: passing a nonexistent candidate UUID + // still returns unfiltered results) — the correct path for a + // candidate's applications is ashby_get_candidate's applicationIds. + expect(AshbyBlock.subBlocks.find((s) => s.id === 'filterCandidateId')).toBeUndefined() + const result = AshbyBlock.tools.config.params!( + buildParams('list_applications', { filterCandidateId: 'some-id' }) + ) + expect(result.candidateId).toBeUndefined() + }) + }) }) diff --git a/apps/sim/blocks/blocks/ashby.ts b/apps/sim/blocks/blocks/ashby.ts index 757842f6ffa..78c63efee30 100644 --- a/apps/sim/blocks/blocks/ashby.ts +++ b/apps/sim/blocks/blocks/ashby.ts @@ -396,14 +396,6 @@ Output only the ISO 8601 timestamp string, nothing else.`, condition: { field: 'operation', value: 'list_applications' }, mode: 'advanced', }, - { - id: 'filterCandidateId', - title: 'Candidate ID Filter', - type: 'short-input', - placeholder: 'Filter by candidate UUID', - condition: { field: 'operation', value: 'list_applications' }, - mode: 'advanced', - }, { id: 'createdAfter', title: 'Created After', @@ -762,9 +754,6 @@ Output only the JSON array, nothing else.`, if (params.searchEmail) result.email = params.searchEmail if (params.filterStatus) result.status = params.filterStatus if (params.filterJobId) result.jobId = params.filterJobId - if (params.operation === 'list_applications' && params.filterCandidateId) { - result.candidateId = params.filterCandidateId - } if (params.jobStatus) result.status = params.jobStatus if (params.sendNotifications === 'true' || params.sendNotifications === true) { result.sendNotifications = true @@ -853,7 +842,6 @@ Output only the JSON array, nothing else.`, sendNotifications: { type: 'boolean', description: 'Send notifications' }, filterStatus: { type: 'string', description: 'Application status filter' }, filterJobId: { type: 'string', description: 'Job UUID filter' }, - filterCandidateId: { type: 'string', description: 'Candidate UUID filter' }, createdAfter: { type: 'string', description: 'Filter by creation date' }, openedAfter: { type: 'string', description: 'Filter jobs opened after this timestamp' }, openedBefore: { type: 'string', description: 'Filter jobs opened before this timestamp' }, diff --git a/apps/sim/tools/ashby/list_applications.ts b/apps/sim/tools/ashby/list_applications.ts index e347f083612..00201c9698d 100644 --- a/apps/sim/tools/ashby/list_applications.ts +++ b/apps/sim/tools/ashby/list_applications.ts @@ -51,12 +51,6 @@ export const listApplicationsTool: ToolConfig< visibility: 'user-or-llm', description: 'Filter applications by a specific job UUID', }, - candidateId: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Filter applications by a specific candidate UUID', - }, createdAfter: { type: 'string', required: false, @@ -76,7 +70,6 @@ export const listApplicationsTool: ToolConfig< if (params.perPage) body.limit = params.perPage if (params.status) body.status = params.status if (params.jobId) body.jobId = params.jobId.trim() - if (params.candidateId) body.candidateId = params.candidateId.trim() if (params.createdAfter) { const ms = new Date(params.createdAfter).getTime() if (!Number.isNaN(ms)) body.createdAfter = ms diff --git a/apps/sim/tools/ashby/types.ts b/apps/sim/tools/ashby/types.ts index 4dc5e03892b..800728b5948 100644 --- a/apps/sim/tools/ashby/types.ts +++ b/apps/sim/tools/ashby/types.ts @@ -148,7 +148,6 @@ export interface AshbyListApplicationsParams extends AshbyBaseParams { perPage?: number status?: string jobId?: string - candidateId?: string createdAfter?: string } From 78d47f72b32b055444341538c918db9fbb2116c3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 11 Jul 2026 21:50:04 -0700 Subject: [PATCH 4/4] fix(ashby): add subblock-id migration for the removed filterCandidateId field The subblock ID stability CI check correctly caught that removing filterCandidateId without a migration entry would silently drop the value on already-deployed workflows. Added the standard _removed_ mapping, following the same pattern already used for this block's prior removals (emailType, phoneType, expandApplicationFormDefinition, expandSurveyFormDefinitions). --- apps/sim/lib/workflows/migrations/subblock-migrations.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index d56d51ff055..a97d73e4e56 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -46,6 +46,7 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { phoneType: '_removed_phoneType', expandApplicationFormDefinition: '_removed_expandApplicationFormDefinition', expandSurveyFormDefinitions: '_removed_expandSurveyFormDefinitions', + filterCandidateId: '_removed_filterCandidateId', }, apollo: { contact_ids_bulk: 'contacts',