Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/sim/blocks/blocks/harmonic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ export const HarmonicBlock: BlockConfig = {
language: 'json',
placeholder: '["urn:harmonic:person:22", "urn:harmonic:person:1690"]',
description:
'Batch Get requires at least one Person URN or Person ID. Clear Net-New Results clears everything when omitted',
'Batch Get requires at least one Person URN or Person ID. Clear Net-New Results requires at least one URN unless Clear Scope is set to every net-new result',
condition: { field: 'operation', value: [...PERSON_URN_OPERATIONS] },
paramVisibility: 'user-or-llm',
wandConfig: {
Expand Down Expand Up @@ -838,7 +838,7 @@ export const HarmonicBlockMeta = {
description:
'Turn LinkedIn URLs or email addresses a workflow already holds into Harmonic contacts.',
content:
'# Enrich Known Identifiers\n\nUse Enrich Person when the workflow already has an identifier rather than a description of who to find.\n\n## Steps\n1. Prefer the LinkedIn profile URL; supply the email only as a fallback identifier.\n2. Run Enrich Person once per identifier and keep personUrn from every match.\n3. When Harmonic reports the person is not on file, capture the enrichment it scheduled and poll Get Enrichment Status until it is COMPLETE or FAILED.\n4. Read the resulting person with Get Person or Batch Get People once enrichment completes.\n\n## Output\nReturn the hydrated contacts, the identifiers still pending enrichment, and the identifiers Harmonic could not resolve. Do not invent contact fields for unresolved rows.',
'# Enrich Known Identifiers\n\nUse Enrich Person when the workflow already has an identifier rather than a description of who to find.\n\n## Steps\n1. Prefer the LinkedIn profile URL; supply the email only as a fallback identifier.\n2. Run Enrich Person once per identifier and keep personUrn from every match.\n3. A person Harmonic does not have yet fails the block rather than returning a row: the error names the enrichment that was scheduled and carries its URN. Handle that error instead of treating it as a match, and poll Get Enrichment Status with the URN until it is COMPLETE or FAILED.\n4. Read the resulting person with Get Person or Batch Get People once enrichment completes.\n\n## Output\nReturn the hydrated contacts, the identifiers still pending enrichment, and the identifiers Harmonic could not resolve. Do not invent contact fields for unresolved rows.',
},
{
name: 'source-company-employees',
Expand Down
22 changes: 20 additions & 2 deletions apps/sim/tools/error-extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
{
id: 'harmonic-errors',
description:
'Harmonic API message errors, string and object FastAPI detail aborts including the enrichment URN, and validation detail arrays without echoed request input',
'Harmonic API message errors, string and object FastAPI detail aborts including the enrichment URN, bulk email-enrichment error codes with their quota counters, and validation detail arrays without echoed request input',
examples: ['Harmonic'],
extract: (errorInfo) => {
const data = errorInfo?.data
Expand Down Expand Up @@ -241,12 +241,30 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
if (data.detail && typeof data.detail === 'object' && !Array.isArray(data.detail)) {
const detail = data.detail as { message?: unknown; enrichment_urn?: unknown }
const detailMessage = typeof detail.message === 'string' ? detail.message.trim() : ''
if (!detailMessage) return undefined
const enrichmentUrn =
typeof detail.enrichment_urn === 'string' ? detail.enrichment_urn.trim() : ''
if (!detailMessage) return enrichmentUrn || undefined
return enrichmentUrn ? `${detailMessage} (${enrichmentUrn})` : detailMessage
}

/**
* The bulk email-enrichment endpoint answers 422/429 with a code in `error`
* and no message anywhere — `{error: 'MONTHLY_QUOTA_INSUFFICIENT', needed,
* available, submitted}`. These are the most actionable failures on that path.
*
* Gated on one of the documented numeric counters being present. `error` alone
* is far too common a key to claim: `extractErrorMessage` without an explicit
* id walks every extractor in order, so a bare `error` check here would swallow
* OAuth's `{error, error_description}` and return the code instead of the text.
*/
const emailJobCounters = (['needed', 'available', 'submitted'] as const).filter(
(key) => typeof data[key] === 'number'
)
if (typeof data.error === 'string' && data.error.trim() && emailJobCounters.length > 0) {
const code = data.error.trim()
return `${code} (${emailJobCounters.map((key) => `${key} ${data[key]}`).join(', ')})`
}

if (!Array.isArray(data.detail)) return undefined
const details = data.detail
.map((entry: unknown) => {
Expand Down
164 changes: 163 additions & 1 deletion apps/sim/tools/harmonic/harmonic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ describe('Harmonic authentication and registry-facing contracts', () => {
expect(headers.Authorization).toBeUndefined()
}

/** One sample per registered tool: every URL builder interpolates user input. */
const requestSamples: Array<[ToolConfig, Record<string, unknown>]> = [
[harmonicSearchPeopleScoutTool, { accessToken: 'team-secret', query: 'find FDEs' }],
[harmonicListPeopleSavedSearchesTool, { accessToken: 'team-secret' }],
Expand All @@ -238,7 +239,35 @@ describe('Harmonic authentication and registry-facing contracts', () => {
{ accessToken: 'team-secret', savedSearchId: 'urn:harmonic:saved_search:1' },
],
[harmonicBatchGetPeopleTool, { accessToken: 'team-secret', personIds: [1] }],
[
harmonicEnrichPersonTool,
{ accessToken: 'team-secret', linkedinUrl: 'https://www.linkedin.com/in/ada' },
],
[harmonicGetPersonTool, { accessToken: 'team-secret', personId: '123' }],
[harmonicGetCompanyEmployeesTool, { accessToken: 'team-secret', companyId: '1' }],
[
harmonicGetPeopleSavedSearchNetNewResultsTool,
{ accessToken: 'team-secret', savedSearchId: '5' },
],
[
harmonicClearPeopleSavedSearchNetNewResultsTool,
{ accessToken: 'team-secret', savedSearchId: '5', clearScope: 'all' },
],
[
harmonicSubmitEmailEnrichmentJobTool,
{ accessToken: 'team-secret', personUrns: ['urn:harmonic:person:1'] },
],
[harmonicGetEmailEnrichmentJobTool, { accessToken: 'team-secret', jobId: 'job-1' }],
[harmonicGetEmailEnrichmentUsageTool, { accessToken: 'team-secret' }],
[
harmonicGetEnrichmentStatusTool,
{ accessToken: 'team-secret', enrichmentUrns: ['urn:harmonic:enrichment:1'] },
],
]
expect(requestSamples).toHaveLength(allTools.length)
expect(new Set(requestSamples.map(([tool]) => tool.id))).toEqual(
new Set(allTools.map((tool) => tool.id))
)
for (const [tool, params] of requestSamples) {
expect(buildUrl(tool, params)).not.toContain('team-secret')
if (tool.request.body)
Expand Down Expand Up @@ -336,6 +365,13 @@ describe('Harmonic authentication and registry-facing contracts', () => {
{ status: 404, data: { detail: { enrichment_urn: 'urn:harmonic:enrichment:abc' } } },
harmonicEnrichPersonTool.errorExtractor
)
).toBe('urn:harmonic:enrichment:abc')

expect(
extractErrorMessage(
{ status: 404, data: { detail: {} } },
harmonicEnrichPersonTool.errorExtractor
)
).toBe('Request failed with status 404')
})

Expand Down Expand Up @@ -564,7 +600,6 @@ describe('Harmonic people retrieval', () => {
['entity_urn', 'urn:harmonic:company:1'],
['name', ' '],
['creator', 'urn:harmonic:company:1'],
['user_saved_search_type', 'UNKNOWN'],
['created_at', 'yesterday'],
['created_at', '2026-02-31T12:34:56Z'],
['created_at', '2026-01-01T00:00:60Z'],
Expand All @@ -579,6 +614,14 @@ describe('Harmonic people retrieval', () => {
).rejects.toThrow(/saved search/)
})

it('passes an unrecognized user_saved_search_type through instead of failing the list', async () => {
const result = await harmonicListPeopleSavedSearchesTool.transformResponse!(
jsonResponse([{ ...validPeopleSavedSearch, user_saved_search_type: 'SOMETHING_NEW' }])
)
expect(result.output.savedSearches).toHaveLength(1)
expect(result.output.savedSearches[0].userSavedSearchType).toBe('SOMETHING_NEW')
})

it.each([
'id',
'entity_urn',
Expand Down Expand Up @@ -929,6 +972,15 @@ describe('Harmonic person enrichment', () => {
}
})

it('rejects company context URNs from another entity family', () => {
expect(() =>
buildUrl(harmonicGetPersonTool, {
personId: '123',
companyContextUrns: ['urn:harmonic:person:1'],
})
).toThrow('"companyContextUrns" must contain only company URNs')
})

it('repeats company context URNs as query parameters', () => {
expect(
buildUrl(harmonicGetPersonTool, {
Expand Down Expand Up @@ -1098,6 +1150,116 @@ describe('Harmonic email enrichment', () => {
).toThrow('must contain absolute http(s) URLs')
})

it('folds every spelling of one profile into a single submitted entry', () => {
expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personLinkedinUrls: [
'https://www.linkedin.com/in/ada?utm_source=x',
'https://www.linkedin.com/in/ada',
'https://www.linkedin.com/in/ada#about',
'https://www.linkedin.com/in/ada/',
'https://linkedin.com/in/ada',
'https://WWW.LinkedIn.com/in/ada',
],
})
).toEqual({ person_linkedin_urls: ['https://www.linkedin.com/in/ada'] })
})

it('keeps pass-through URLs distinct on every component Harmonic still sees', () => {
expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personLinkedinUrls: [
'https://profiles.example/p?id=1',
'https://profiles.example/p?id=2',
'https://profiles.example:8443/p',
'https://profiles.example/p#a',
'https://profiles.example/p#b',
],
})
).toEqual({
person_linkedin_urls: [
'https://profiles.example/p?id=1',
'https://profiles.example/p?id=2',
'https://profiles.example:8443/p',
'https://profiles.example/p#a',
'https://profiles.example/p#b',
],
})

expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personLinkedinUrls: ['https://profiles.example/p?id=1', 'https://profiles.example/p?id=1'],
})
).toEqual({ person_linkedin_urls: ['https://profiles.example/p?id=1'] })
})

it('keeps regional subdomains distinct rather than assuming an undocumented equivalence', () => {
expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personLinkedinUrls: ['https://uk.linkedin.com/in/ada', 'https://www.linkedin.com/in/ada'],
})
).toEqual({
person_linkedin_urls: ['https://uk.linkedin.com/in/ada', 'https://www.linkedin.com/in/ada'],
})
})

it('treats blank LinkedIn entries as absent instead of a conflicting identifier list', () => {
expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personUrns: ['urn:harmonic:person:1'],
personLinkedinUrls: ['', ' ', null],
})
).toEqual({ person_urns: ['urn:harmonic:person:1'] })

expect(() =>
buildBody(harmonicSubmitEmailEnrichmentJobTool, { personLinkedinUrls: ['', ' '] })
).toThrow('requires at least one person URN or LinkedIn profile URL')
})

it('reports the identifier conflict before complaining about any single URL', () => {
expect(() =>
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
personUrns: ['urn:harmonic:person:1'],
personLinkedinUrls: ['not-a-url'],
})
).toThrow('accepts person URNs or LinkedIn URLs, not both')
})

it('surfaces the bulk email error codes with their quota counters', () => {
expect(
extractErrorMessage(
{
status: 429,
data: {
error: 'MONTHLY_QUOTA_INSUFFICIENT',
needed: 500,
available: 20,
submitted: 500,
},
},
harmonicSubmitEmailEnrichmentJobTool.errorExtractor
)
).toBe('MONTHLY_QUOTA_INSUFFICIENT (needed 500, available 20, submitted 500)')

expect(
extractErrorMessage(
{ status: 422, data: { error: 'NO_ELIGIBLE_PEOPLE', submitted: 3, dropped: [] } },
harmonicSubmitEmailEnrichmentJobTool.errorExtractor
)
).toBe('NO_ELIGIBLE_PEOPLE (submitted 3)')

/**
* `extractErrorMessage` with no id walks every extractor in order, so a bare
* `error` key here would hijack other providers' envelopes.
*/
expect(
extractErrorMessage({
status: 400,
data: { error: 'invalid_grant', error_description: 'The grant is invalid' },
})
).toBe('The grant is invalid')
})

it('forwards unrecognised profile URLs so Harmonic can drop them per item', () => {
expect(
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
Expand Down
18 changes: 0 additions & 18 deletions apps/sim/tools/harmonic/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,24 +120,6 @@ export interface HarmonicEnrichmentOutput {
enriched_entity_urn?: unknown
}

export interface HarmonicDroppedPerson {
submitted_identifier?: unknown
reason?: unknown
}

export interface HarmonicPersonJobResultOutput {
person_urn?: unknown
status?: unknown
}

export interface HarmonicPersonJobCountsOutput {
total_processed?: unknown
total_succeeded?: unknown
total_failed?: unknown
total_skipped?: unknown
total_not_found?: unknown
}

export interface HarmonicEnrichmentStatus {
enrichmentUrn: string | null
status: string | null
Expand Down
Loading
Loading