Skip to content

Commit 5dec4f3

Browse files
authored
fix(ashby): parse alternateEmailAddresses and socialLinks into arrays before dispatch (#5621)
* 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. * 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. * fix(ashby): remove the non-functional candidateId filter from list_applications 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. * 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).
1 parent f9b09ec commit 5dec4f3

5 files changed

Lines changed: 135 additions & 22 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { AshbyBlock } from './ashby'
6+
7+
describe('AshbyBlock', () => {
8+
const buildParams = (operation: string, extra: Record<string, unknown>) => ({
9+
operation,
10+
...extra,
11+
})
12+
13+
describe('alternateEmailAddresses parsing (create_candidate)', () => {
14+
it('parses a comma-separated string into an array', () => {
15+
const result = AshbyBlock.tools.config.params!(
16+
buildParams('create_candidate', {
17+
alternateEmailAddresses: 'a@x.com, b@x.com',
18+
})
19+
)
20+
expect(result.alternateEmailAddresses).toEqual(['a@x.com', 'b@x.com'])
21+
})
22+
23+
it('parses a JSON array string into an array', () => {
24+
const result = AshbyBlock.tools.config.params!(
25+
buildParams('create_candidate', {
26+
alternateEmailAddresses: '["a@x.com","b@x.com"]',
27+
})
28+
)
29+
expect(result.alternateEmailAddresses).toEqual(['a@x.com', 'b@x.com'])
30+
})
31+
32+
it('omits the field entirely when empty', () => {
33+
const result = AshbyBlock.tools.config.params!(
34+
buildParams('create_candidate', { alternateEmailAddresses: '' })
35+
)
36+
expect(result.alternateEmailAddresses).toBeUndefined()
37+
})
38+
})
39+
40+
describe('socialLinks parsing (update_candidate)', () => {
41+
it('parses a JSON array of link objects', () => {
42+
const result = AshbyBlock.tools.config.params!(
43+
buildParams('update_candidate', {
44+
socialLinks: '[{"type":"Twitter","url":"https://twitter.com/jane"}]',
45+
})
46+
)
47+
expect(result.socialLinks).toEqual([{ type: 'Twitter', url: 'https://twitter.com/jane' }])
48+
})
49+
50+
it('omits the field when the JSON is malformed', () => {
51+
const result = AshbyBlock.tools.config.params!(
52+
buildParams('update_candidate', { socialLinks: 'not json' })
53+
)
54+
expect(result.socialLinks).toBeUndefined()
55+
})
56+
})
57+
58+
describe('wandConfig on array-shaped fields', () => {
59+
it('does not request object-wrapped output for alternateEmailAddresses or socialLinks', () => {
60+
// generationType 'json-object' makes the wand API append "the response
61+
// must start with { and end with }", which conflicts with these fields'
62+
// array-or-comma-separated parsers (parseStringListInput/parseSocialLinksInput).
63+
const alternateEmailAddresses = AshbyBlock.subBlocks.find(
64+
(s) => s.id === 'alternateEmailAddresses'
65+
)
66+
const socialLinks = AshbyBlock.subBlocks.find((s) => s.id === 'socialLinks')
67+
expect(alternateEmailAddresses?.wandConfig?.generationType).not.toBe('json-object')
68+
expect(socialLinks?.wandConfig?.generationType).not.toBe('json-object')
69+
})
70+
})
71+
72+
describe('list_applications candidateId filter', () => {
73+
it('has no filterCandidateId subBlock or wiring', () => {
74+
// Ashby's application.list endpoint silently ignores a candidateId
75+
// body field (confirmed live: passing a nonexistent candidate UUID
76+
// still returns unfiltered results) — the correct path for a
77+
// candidate's applications is ashby_get_candidate's applicationIds.
78+
expect(AshbyBlock.subBlocks.find((s) => s.id === 'filterCandidateId')).toBeUndefined()
79+
const result = AshbyBlock.tools.config.params!(
80+
buildParams('list_applications', { filterCandidateId: 'some-id' })
81+
)
82+
expect(result.candidateId).toBeUndefined()
83+
})
84+
})
85+
})

apps/sim/blocks/blocks/ashby.ts

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,34 @@ import { AshbyIcon } from '@/components/icons'
22
import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types'
33
import { getTrigger } from '@/triggers'
44

5+
function parseStringListInput(value: unknown): string[] {
6+
if (Array.isArray(value)) return value.map(String)
7+
if (typeof value !== 'string') return []
8+
const trimmed = value.trim()
9+
if (!trimmed) return []
10+
if (trimmed.startsWith('[')) {
11+
try {
12+
const parsed = JSON.parse(trimmed)
13+
if (Array.isArray(parsed)) return parsed.map(String)
14+
} catch {}
15+
}
16+
return trimmed
17+
.split(',')
18+
.map((s) => s.trim())
19+
.filter(Boolean)
20+
}
21+
22+
function parseSocialLinksInput(value: unknown): Array<{ type: string; url: string }> {
23+
if (Array.isArray(value)) return value as Array<{ type: string; url: string }>
24+
if (typeof value !== 'string' || !value.trim()) return []
25+
try {
26+
const parsed = JSON.parse(value)
27+
return Array.isArray(parsed) ? parsed : []
28+
} catch {
29+
return []
30+
}
31+
}
32+
533
export const AshbyBlock: BlockConfig = {
634
type: 'ashby',
735
name: 'Ashby',
@@ -368,14 +396,6 @@ Output only the ISO 8601 timestamp string, nothing else.`,
368396
condition: { field: 'operation', value: 'list_applications' },
369397
mode: 'advanced',
370398
},
371-
{
372-
id: 'filterCandidateId',
373-
title: 'Candidate ID Filter',
374-
type: 'short-input',
375-
placeholder: 'Filter by candidate UUID',
376-
condition: { field: 'operation', value: 'list_applications' },
377-
mode: 'advanced',
378-
},
379399
{
380400
id: 'createdAfter',
381401
title: 'Created After',
@@ -563,6 +583,13 @@ Output only the ISO 8601 timestamp string, nothing else.`,
563583
placeholder: 'Comma-separated or JSON array (e.g. ["a@x.com","b@x.com"])',
564584
condition: { field: 'operation', value: 'create_candidate' },
565585
mode: 'advanced',
586+
wandConfig: {
587+
enabled: true,
588+
prompt: `Generate a comma-separated or JSON array of email addresses based on the user's description.
589+
Examples:
590+
- "her work and personal emails" -> ["work@company.com","personal@example.com"]
591+
Output only the list, nothing else.`,
592+
},
566593
},
567594
{
568595
id: 'socialLinks',
@@ -571,6 +598,13 @@ Output only the ISO 8601 timestamp string, nothing else.`,
571598
placeholder: 'JSON array (e.g. [{"type":"Twitter","url":"https://twitter.com/x"}])',
572599
condition: { field: 'operation', value: 'update_candidate' },
573600
mode: 'advanced',
601+
wandConfig: {
602+
enabled: true,
603+
prompt: `Generate a JSON array of social link objects ({"type","url"}) based on the user's description.
604+
Examples:
605+
- "his Twitter is @jane and portfolio is jane.dev" -> [{"type":"Twitter","url":"https://twitter.com/jane"},{"type":"Portfolio","url":"https://jane.dev"}]
606+
Output only the JSON array, nothing else.`,
607+
},
574608
},
575609
{
576610
id: 'includeArchived',
@@ -720,9 +754,6 @@ Output only the ISO 8601 timestamp string, nothing else.`,
720754
if (params.searchEmail) result.email = params.searchEmail
721755
if (params.filterStatus) result.status = params.filterStatus
722756
if (params.filterJobId) result.jobId = params.filterJobId
723-
if (params.operation === 'list_applications' && params.filterCandidateId) {
724-
result.candidateId = params.filterCandidateId
725-
}
726757
if (params.jobStatus) result.status = params.jobStatus
727758
if (params.sendNotifications === 'true' || params.sendNotifications === true) {
728759
result.sendNotifications = true
@@ -772,9 +803,14 @@ Output only the ISO 8601 timestamp string, nothing else.`,
772803
result.applicationId = params.offerApplicationId
773804
}
774805
if (params.alternateEmailAddresses) {
775-
result.alternateEmailAddresses = params.alternateEmailAddresses
806+
const alternateEmailAddresses = parseStringListInput(params.alternateEmailAddresses)
807+
if (alternateEmailAddresses.length > 0)
808+
result.alternateEmailAddresses = alternateEmailAddresses
809+
}
810+
if (params.socialLinks) {
811+
const socialLinks = parseSocialLinksInput(params.socialLinks)
812+
if (socialLinks.length > 0) result.socialLinks = socialLinks
776813
}
777-
if (params.socialLinks) result.socialLinks = params.socialLinks
778814
return result
779815
},
780816
},
@@ -806,7 +842,6 @@ Output only the ISO 8601 timestamp string, nothing else.`,
806842
sendNotifications: { type: 'boolean', description: 'Send notifications' },
807843
filterStatus: { type: 'string', description: 'Application status filter' },
808844
filterJobId: { type: 'string', description: 'Job UUID filter' },
809-
filterCandidateId: { type: 'string', description: 'Candidate UUID filter' },
810845
createdAfter: { type: 'string', description: 'Filter by creation date' },
811846
openedAfter: { type: 'string', description: 'Filter jobs opened after this timestamp' },
812847
openedBefore: { type: 'string', description: 'Filter jobs opened before this timestamp' },

apps/sim/lib/workflows/migrations/subblock-migrations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const SUBBLOCK_ID_MIGRATIONS: Record<string, Record<string, string>> = {
4646
phoneType: '_removed_phoneType',
4747
expandApplicationFormDefinition: '_removed_expandApplicationFormDefinition',
4848
expandSurveyFormDefinitions: '_removed_expandSurveyFormDefinitions',
49+
filterCandidateId: '_removed_filterCandidateId',
4950
},
5051
apollo: {
5152
contact_ids_bulk: 'contacts',

apps/sim/tools/ashby/list_applications.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,6 @@ export const listApplicationsTool: ToolConfig<
5151
visibility: 'user-or-llm',
5252
description: 'Filter applications by a specific job UUID',
5353
},
54-
candidateId: {
55-
type: 'string',
56-
required: false,
57-
visibility: 'user-or-llm',
58-
description: 'Filter applications by a specific candidate UUID',
59-
},
6054
createdAfter: {
6155
type: 'string',
6256
required: false,
@@ -76,7 +70,6 @@ export const listApplicationsTool: ToolConfig<
7670
if (params.perPage) body.limit = params.perPage
7771
if (params.status) body.status = params.status
7872
if (params.jobId) body.jobId = params.jobId.trim()
79-
if (params.candidateId) body.candidateId = params.candidateId.trim()
8073
if (params.createdAfter) {
8174
const ms = new Date(params.createdAfter).getTime()
8275
if (!Number.isNaN(ms)) body.createdAfter = ms

apps/sim/tools/ashby/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,6 @@ export interface AshbyListApplicationsParams extends AshbyBaseParams {
148148
perPage?: number
149149
status?: string
150150
jobId?: string
151-
candidateId?: string
152151
createdAfter?: string
153152
}
154153

0 commit comments

Comments
 (0)