Skip to content

Commit 7f987ce

Browse files
committed
fix(tools): render Ashby object-shaped API errors readably
Ashby documents two error shapes and uses both. The `errors` array form carries `{ message, parameter }` objects, which stringified to '[object Object]' and hid the real cause - including the 403 a key gets when it lacks a module permission. Also adds the shared pieces the new write operations need: one definition of the custom field value shape for the read and write paths to agree on, and a normalizer for Ashby's case-sensitive objectType enum so a model emitting 'candidate' fails here with the allowed values rather than at the API.
1 parent ff584ef commit 7f987ce

2 files changed

Lines changed: 129 additions & 19 deletions

File tree

apps/sim/tools/ashby/utils.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { ashbyErrorMessage } from '@/tools/ashby/utils'
6+
7+
describe('ashbyErrorMessage', () => {
8+
it('reads the message out of the documented { message, parameter } entries', () => {
9+
// This is the shape Ashby's OpenAPI definition declares, and the one a 403
10+
// for a missing module permission arrives in. Stringifying the entry
11+
// directly yields '[object Object]' and hides the real cause.
12+
expect(
13+
ashbyErrorMessage(
14+
{ success: false, errors: [{ message: 'missing_endpoint_permission' }] },
15+
'fallback'
16+
)
17+
).toBe('missing_endpoint_permission')
18+
})
19+
20+
it('names the offending parameter when Ashby supplies one', () => {
21+
expect(
22+
ashbyErrorMessage(
23+
{ success: false, errors: [{ message: 'Invalid value', parameter: 'fieldValue' }] },
24+
'fallback'
25+
)
26+
).toBe('Invalid value (fieldValue)')
27+
})
28+
29+
it('joins multiple errors', () => {
30+
expect(
31+
ashbyErrorMessage(
32+
{ success: false, errors: [{ message: 'a' }, { message: 'b' }] },
33+
'fallback'
34+
)
35+
).toBe('a; b')
36+
})
37+
38+
it('still handles the plain string array form', () => {
39+
expect(ashbyErrorMessage({ success: false, errors: ['boom'] }, 'fallback')).toBe('boom')
40+
})
41+
42+
it('prefers errorInfo.message, the other documented shape', () => {
43+
expect(
44+
ashbyErrorMessage({ success: false, errorInfo: { message: 'rate limited' } }, 'fallback')
45+
).toBe('rate limited')
46+
})
47+
48+
it('falls back when the entries carry no usable message', () => {
49+
expect(ashbyErrorMessage({ success: false, errors: [{ parameter: 'x' }] }, 'fallback')).toBe(
50+
'fallback'
51+
)
52+
expect(ashbyErrorMessage({ success: false, errors: [] }, 'fallback')).toBe('fallback')
53+
expect(ashbyErrorMessage(null, 'fallback')).toBe('fallback')
54+
})
55+
})

apps/sim/tools/ashby/utils.ts

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,32 @@ export function ashbyAuthHeaders(apiKey: string): Record<string, string> {
3131

3232
/**
3333
* Extract a human-readable error message from an Ashby error response. Ashby
34-
* returns errors as either `errorInfo.message` or an `errors` string array.
34+
* documents two shapes and uses both: `errorInfo.message`, and an `errors`
35+
* array whose entries are either plain strings or `{ message, parameter }`
36+
* objects. An object entry stringifies to `[object Object]` unless its message
37+
* is read explicitly, which is the form a 403 for a missing module permission
38+
* arrives in.
3539
*/
3640
export function ashbyErrorMessage(data: unknown, fallback: string): string {
3741
if (!data || typeof data !== 'object') return fallback
3842
const d = data as Unknown
3943
const info = d.errorInfo as Unknown | undefined
4044
if (info && typeof info.message === 'string' && info.message) return info.message
4145
if (Array.isArray(d.errors) && d.errors.length > 0) {
42-
return d.errors.map((e) => String(e)).join('; ')
46+
const messages = d.errors
47+
.map((e) => {
48+
if (typeof e === 'string') return e
49+
if (e && typeof e === 'object') {
50+
const entry = e as Unknown
51+
const message = typeof entry.message === 'string' ? entry.message : ''
52+
const parameter = typeof entry.parameter === 'string' ? entry.parameter : ''
53+
if (message && parameter) return `${message} (${parameter})`
54+
if (message) return message
55+
}
56+
return ''
57+
})
58+
.filter(Boolean)
59+
if (messages.length > 0) return messages.join('; ')
4360
}
4461
return fallback
4562
}
@@ -59,18 +76,44 @@ function mapContactArray(raw: unknown): AshbyContactInfo[] {
5976
return raw.map((c) => mapContact(c)).filter((c): c is AshbyContactInfo => c !== null)
6077
}
6178

79+
const CUSTOM_FIELD_OBJECT_TYPES = ['Application', 'Candidate', 'Job', 'Opening'] as const
80+
81+
/**
82+
* Normalize and validate the objectType a custom field write targets. Ashby's
83+
* enum is case-sensitive, and this param is `user-or-llm` - a model emitting
84+
* `candidate` instead of `Candidate` would otherwise fail at the API with a
85+
* generic error instead of here with a message naming the allowed values.
86+
*/
87+
export function normalizeObjectType(value: string): string {
88+
const trimmed = (value ?? '').trim()
89+
const match = CUSTOM_FIELD_OBJECT_TYPES.find((t) => t.toLowerCase() === trimmed.toLowerCase())
90+
if (!match) {
91+
throw new Error(
92+
`Invalid Ashby object type "${value}". Expected one of: ${CUSTOM_FIELD_OBJECT_TYPES.join(', ')}.`
93+
)
94+
}
95+
return match
96+
}
97+
98+
/**
99+
* Map a single custom field value as returned on an object. Ashby returns
100+
* `valueLabel` as a string for ValueSelect fields and an array of strings for
101+
* MultiValueSelect, and omits it entirely for every other field type.
102+
*/
103+
export function mapCustomFieldOnObject(raw: unknown): AshbyCustomField {
104+
const cf = (raw ?? {}) as Unknown
105+
return {
106+
id: (cf.id as string) ?? null,
107+
title: (cf.title as string) ?? '',
108+
isPrivate: (cf.isPrivate as boolean) ?? false,
109+
valueLabel: (cf.valueLabel as string | string[]) ?? null,
110+
value: cf.value ?? null,
111+
}
112+
}
113+
62114
function mapCustomFields(raw: unknown): AshbyCustomField[] {
63115
if (!Array.isArray(raw)) return []
64-
return raw.map((f) => {
65-
const cf = f as Unknown
66-
return {
67-
id: (cf.id as string) ?? null,
68-
title: (cf.title as string) ?? '',
69-
isPrivate: (cf.isPrivate as boolean) ?? false,
70-
valueLabel: (cf.valueLabel as string) ?? null,
71-
value: cf.value ?? null,
72-
}
73-
})
116+
return raw.map(mapCustomFieldOnObject)
74117
}
75118

76119
function mapFileHandle(raw: unknown): AshbyFileHandle | null {
@@ -373,18 +416,30 @@ export const CONTACT_INFO_OUTPUT = {
373416
},
374417
} as const satisfies OutputProperty
375418

419+
/**
420+
* Shape of a custom field as it exists on an Application, Candidate, Job, or
421+
* Opening - the value, not the field definition. Shared by every tool that
422+
* reads or writes custom field values.
423+
*/
424+
export const CUSTOM_FIELD_ON_OBJECT_OUTPUT = {
425+
id: { type: 'string', description: 'Custom field UUID' },
426+
title: { type: 'string', description: 'Field title' },
427+
isPrivate: { type: 'boolean', description: 'Whether the field is private' },
428+
valueLabel: {
429+
type: 'json',
430+
description:
431+
'Human-readable value label, present only for ValueSelect and MultiValueSelect fields. A string for ValueSelect, an array of strings for MultiValueSelect.',
432+
optional: true,
433+
},
434+
value: { type: 'string', description: 'Raw field value (type depends on fieldType)' },
435+
} as const satisfies Record<string, OutputProperty>
436+
376437
export const CUSTOM_FIELDS_OUTPUT = {
377438
type: 'array',
378439
description: 'Custom field values',
379440
items: {
380441
type: 'object',
381-
properties: {
382-
id: { type: 'string', description: 'Custom field UUID' },
383-
title: { type: 'string', description: 'Field title' },
384-
isPrivate: { type: 'boolean', description: 'Whether the field is private' },
385-
valueLabel: { type: 'string', description: 'Human-readable value label', optional: true },
386-
value: { type: 'string', description: 'Raw field value (type depends on fieldType)' },
387-
},
442+
properties: CUSTOM_FIELD_ON_OBJECT_OUTPUT,
388443
},
389444
} as const satisfies OutputProperty
390445

0 commit comments

Comments
 (0)