Skip to content

Commit 4f52ff4

Browse files
committed
feat(blocks): expose the new Ashby operations in the block
fieldValue is polymorphic (boolean, number, string, array, object, null), so it decodes structured input and otherwise passes text through. The decoding is deliberately narrow rather than a blanket JSON.parse, which corrupts real text: 1e999 becomes Infinity and serializes back out as null, which CLEARS the field; a long numeric id loses precision past 2^53; and prose starting with { turns into an object. Only the literal keywords, {, [ or " prefixes, and exactly round-tripping numbers decode. fieldValue carries no wand generationType: json-object forces braces and json-array forces brackets, and both would wrap a value that must stay bare. fieldValues, whose contract really is an array, uses json-array. Setting and clearing an application source are mutually exclusive, so the Source ID field is conditioned off while the clear switch is on and the params mapping sends only the intent the switch selects. A value typed before the switch was flipped cannot reach the tool and surface as an error with no visible cause.
1 parent f3c993a commit 4f52ff4

3 files changed

Lines changed: 516 additions & 6 deletions

File tree

apps/sim/blocks/blocks/ashby.test.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,206 @@ describe('AshbyBlock', () => {
7878
expect(alternateEmailAddresses?.wandConfig?.generationType).not.toBe('json-object')
7979
expect(socialLinks?.wandConfig?.generationType).not.toBe('json-object')
8080
})
81+
82+
it('does not force braces or brackets on the polymorphic fieldValue', () => {
83+
// fieldValue legitimately takes a bare boolean, number, string, or null,
84+
// so neither the 'json-object' nor the 'json-array' reinforcement applies -
85+
// both would make the wand emit a wrapper the field must not receive.
86+
const fieldValue = AshbyBlock.subBlocks.find((s) => s.id === 'fieldValue')
87+
expect(fieldValue?.wandConfig?.enabled).toBe(true)
88+
expect(fieldValue?.wandConfig?.generationType).toBeUndefined()
89+
})
90+
91+
it('requests array output for fieldValues, whose contract is a JSON array', () => {
92+
const fieldValues = AshbyBlock.subBlocks.find((s) => s.id === 'fieldValues')
93+
expect(fieldValues?.wandConfig?.generationType).toBe('json-array')
94+
})
95+
})
96+
97+
describe('fieldValue parsing (set_custom_field_value)', () => {
98+
const parse = (fieldValue: unknown) =>
99+
AshbyBlock.tools.config.params!(buildParams('set_custom_field_value', { fieldValue }))
100+
.fieldValue
101+
102+
it('decodes null so the annotation can be cleared', () => {
103+
// Ashby clears a custom field when it receives an explicit null, which is
104+
// what makes a written annotation reversible.
105+
expect(parse('null')).toBeNull()
106+
})
107+
108+
it('decodes booleans and numbers for Boolean and Number fields', () => {
109+
expect(parse('true')).toBe(true)
110+
expect(parse('42')).toBe(42)
111+
})
112+
113+
it('decodes a JSON array for MultiValueSelect fields', () => {
114+
expect(parse('["Remote","Hybrid"]')).toEqual(['Remote', 'Hybrid'])
115+
})
116+
117+
it('decodes a JSON object for Currency and range fields', () => {
118+
expect(parse('{"value":150000,"currencyCode":"USD"}')).toEqual({
119+
value: 150000,
120+
currencyCode: 'USD',
121+
})
122+
})
123+
124+
it('passes unparseable text through as a plain string', () => {
125+
// A bare option name is the most common input for String, LongText, and
126+
// ValueSelect fields, so it must not be rejected as invalid JSON.
127+
expect(parse('Senior Engineer')).toBe('Senior Engineer')
128+
})
129+
130+
it('decodes a quoted numeric string back to a string', () => {
131+
// The escape hatch for a String field whose value looks like a number.
132+
expect(parse('"123"')).toBe('123')
133+
})
134+
135+
it('does not let an overflowing number become a field clear', () => {
136+
// 1e999 parses to Infinity, which JSON.stringify emits as null - and null
137+
// clears the field. The user typed a number, not a clear.
138+
expect(parse('1e999')).toBe('1e999')
139+
})
140+
141+
it('does not silently lose precision on long numeric ids', () => {
142+
expect(parse('12345678901234567890')).toBe('12345678901234567890')
143+
expect(parse('0123')).toBe('0123')
144+
})
145+
146+
it('leaves prose that merely starts like JSON alone when it does not parse', () => {
147+
expect(parse('{not really json')).toBe('{not really json')
148+
})
149+
150+
it('passes an already-parsed value through untouched', () => {
151+
// An upstream block reference resolves to a real value, not to text.
152+
expect(parse({ value: 1 })).toEqual({ value: 1 })
153+
expect(parse(false)).toBe(false)
154+
})
155+
156+
it('leaves fieldValue alone for other operations', () => {
157+
const result = AshbyBlock.tools.config.params!(
158+
buildParams('list_jobs', { fieldValue: 'Senior Engineer' })
159+
)
160+
expect(result.fieldValue).toBeUndefined()
161+
})
162+
})
163+
164+
describe('fieldValues parsing (set_custom_field_values)', () => {
165+
it('maps the fieldValues subBlock onto the tool’s values param', () => {
166+
const result = AshbyBlock.tools.config.params!(
167+
buildParams('set_custom_field_values', {
168+
fieldValues: '[{"fieldId":"abc","fieldValue":"High"}]',
169+
})
170+
)
171+
expect(result.values).toEqual([{ fieldId: 'abc', fieldValue: 'High' }])
172+
expect(result.fieldValues).toBeUndefined()
173+
})
174+
175+
it('throws instead of silently dropping the writes when the JSON is malformed', () => {
176+
expect(() =>
177+
AshbyBlock.tools.config.params!(
178+
buildParams('set_custom_field_values', { fieldValues: 'not json' })
179+
)
180+
).toThrow(/Invalid JSON in Ashby custom field values/)
181+
})
182+
183+
it('throws when the parsed JSON is not an array', () => {
184+
expect(() =>
185+
AshbyBlock.tools.config.params!(
186+
buildParams('set_custom_field_values', { fieldValues: '{"fieldId":"abc"}' })
187+
)
188+
).toThrow(/expected a JSON array/)
189+
})
190+
})
191+
192+
describe('change_application_source', () => {
193+
it('does not emit a sourceId at all when the field is left blank', () => {
194+
// Blank must not mean "clear": the tool rejects that unless unsetSource is
195+
// set, so an accidentally empty field can no longer wipe attribution.
196+
const result = AshbyBlock.tools.config.params!(
197+
buildParams('change_application_source', { applicationId: 'app-1', changeSourceId: '' })
198+
)
199+
expect(result).not.toHaveProperty('sourceId')
200+
expect(result).not.toHaveProperty('unsetSource')
201+
})
202+
203+
it('passes unsetSource through only when the switch is on', () => {
204+
const result = AshbyBlock.tools.config.params!(
205+
buildParams('change_application_source', { changeSourceId: '', unsetSource: 'true' })
206+
)
207+
expect(result.unsetSource).toBe(true)
208+
})
209+
210+
it('never sends a stale source id alongside a clear request', () => {
211+
// The Source ID field is hidden once the clear switch is on, but a value
212+
// typed beforehand is still stored. Sending both would trip the tool's
213+
// exclusivity guard and surface as an error the user cannot see the cause of.
214+
const result = AshbyBlock.tools.config.params!(
215+
buildParams('change_application_source', {
216+
changeSourceId: 'src-left-over',
217+
unsetSource: 'true',
218+
})
219+
)
220+
expect(result.unsetSource).toBe(true)
221+
expect(result).not.toHaveProperty('sourceId')
222+
})
223+
224+
it('hides the source id field while the clear switch is on', () => {
225+
const sourceField = AshbyBlock.subBlocks.find((s) => s.id === 'changeSourceId')
226+
const condition = sourceField?.condition as { and?: { field: string; not?: boolean } }
227+
expect(condition.and).toEqual({ field: 'unsetSource', value: true, not: true })
228+
})
229+
230+
it('maps a provided source id onto sourceId', () => {
231+
const result = AshbyBlock.tools.config.params!(
232+
buildParams('change_application_source', { changeSourceId: 'src-1' })
233+
)
234+
expect(result.sourceId).toBe('src-1')
235+
})
236+
237+
it('does not emit a null sourceId for other operations', () => {
238+
// create_candidate treats an absent source as "no source", so a null here
239+
// would turn an omitted optional field into an explicit write.
240+
const result = AshbyBlock.tools.config.params!(buildParams('create_candidate', {}))
241+
expect(result).not.toHaveProperty('sourceId')
242+
})
243+
})
244+
245+
describe('operation and tool registration stay in sync', () => {
246+
it('has a matching ashby_<operation> tool in access for every dropdown option', () => {
247+
// tools.config.tool is a bare `ashby_${operation}` concat, so a dropdown
248+
// option without a matching tool id resolves to a tool that does not exist.
249+
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
250+
const optionIds = (operation?.options as Array<{ id: string }>).map((o) => o.id)
251+
const access = new Set(AshbyBlock.tools.access)
252+
const missing = optionIds.filter((id) => !access.has(`ashby_${id}`))
253+
expect(missing).toEqual([])
254+
})
255+
256+
it('has a dropdown option for every tool listed in access', () => {
257+
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
258+
const optionIds = new Set(
259+
(operation?.options as Array<{ id: string }>).map((o) => `ashby_${o.id}`)
260+
)
261+
const unreachable = AshbyBlock.tools.access!.filter((id) => !optionIds.has(id))
262+
expect(unreachable).toEqual([])
263+
})
264+
265+
it('has a canvas sentence for every dropdown option', () => {
266+
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
267+
const optionIds = (operation?.options as Array<{ id: string }>).map((o) => o.id)
268+
const sentences = AshbyBlock.canvasPresentation?.sentences?.byOperation ?? {}
269+
const missing = optionIds.filter((id) => !(id in sentences))
270+
expect(missing).toEqual([])
271+
})
272+
})
273+
274+
describe('list_jobs incremental sync', () => {
275+
it('offers the syncToken field on list_jobs', () => {
276+
// Without a sync token every scheduled run rescans the full req set.
277+
const syncToken = AshbyBlock.subBlocks.find((s) => s.id === 'syncToken')
278+
const condition = syncToken?.condition as { value: string[] }
279+
expect(condition.value).toContain('list_jobs')
280+
})
81281
})
82282

83283
describe('list_applications candidateId filter', () => {

0 commit comments

Comments
 (0)