Skip to content

Commit d8d9838

Browse files
authored
fix(harmonic): correct the destructive-clear copy and stop double-billing enrichment (#6915)
* fix(harmonic): correct the destructive-clear copy and stop double-billing enrichment Follow-up to #6902, from a final validation pass against Harmonic's OpenAPI and API reference. No endpoint, method, or response mapping changed. - The `personUrns` field told users and the LLM that Clear Net-New Results "clears everything when omitted". That is the raw provider behavior the clearScope guard was added to block; omitting it now throws. The field is shared across three operations, so the wrong sentence was being served as guidance on all of them. - Bulk email enrichment deduplicated LinkedIn URLs before canonicalising them, so `.../in/foo?utm_source=x` and `.../in/foo` were submitted as two people. Harmonic bills per submitted entry, so this spent quota twice and double-counted against the 5,000 cap. Deduplicate after canonicalising. - The two documented bulk-enrichment failures carry a code in `error` and no message anywhere, so quota exhaustion surfaced as "Request failed with status 429". Render the code with its counters instead. Gated on those counters being present: `extractErrorMessage` without an explicit id walks every extractor in order, and claiming a bare `error` key swallowed OAuth's `error_description`. - An enrichment 404 whose detail carries only the URN no longer discards it. - Report the identifier conflict before complaining about an individual URL. - Validate `companyContextUrns` as company URNs, like every other URN param. Forward-compat: `user_saved_search_type` is passed through rather than checked against a fixed set. It is display metadata nothing branches on, and Harmonic owns the enum — an allow-list turned any value they add into a hard failure of the whole list while the selector reading the same rows kept working. Also drops `USER_CONNECTION`, which Harmonic documents as unsupported via the API, removes three superseded types and one dead helper, and extends the "credential never reaches a URL or body" assertion from 4 tools to all 13. * fix(harmonic): fold equivalent profile URLs and stop blank entries failing a batch Review round on #6915. - Blank and non-string `personLinkedinUrls` entries are dropped before the mutual-exclusivity check. Moving the filter after per-URL validation meant a list like `['']` alongside person URNs reported "not both" — naming a conflict the caller never created — or failed the URL parse instead of reading as absent. - Deduplicate on a profile key rather than the canonical string, so `linkedin.com/in/x`, `www.linkedin.com/in/x` and a trailing slash count once. Harmonic canonicalizes and silently deduplicates server-side and reserves quota afterwards, so this does not change what is billed; it keeps Sim's own 1-5000 accounting in step with the set Harmonic accepts, so a batch of equivalent URLs is not rejected locally for a cap it never reaches. Regional subdomains stay distinct: folding `uk.linkedin.com` into `www.` would assert an equivalence Harmonic does not document, and the URL kept for display must remain the one the caller supplied. * fix(harmonic): fold only recognized profile URLs, never pass-through ones Review round on #6915. The profile key added last round was applied to every entry, but it is built from host and path alone. A URL forwarded verbatim for Harmonic to adjudicate keeps its query, port and fragment significant, so two distinct identifiers collapsed to one key and the later one was dropped before Harmonic ever saw it. The key now applies only to a URL `normalizeLinkedinProfileUrl` already canonicalized — where the query and fragment are gone by construction, so folding host and trailing slash is safe. Anything passed through deduplicates on its exact text.
1 parent 5b28da1 commit d8d9838

5 files changed

Lines changed: 263 additions & 56 deletions

File tree

apps/sim/blocks/blocks/harmonic.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ export const HarmonicBlock: BlockConfig = {
358358
language: 'json',
359359
placeholder: '["urn:harmonic:person:22", "urn:harmonic:person:1690"]',
360360
description:
361-
'Batch Get requires at least one Person URN or Person ID. Clear Net-New Results clears everything when omitted',
361+
'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',
362362
condition: { field: 'operation', value: [...PERSON_URN_OPERATIONS] },
363363
paramVisibility: 'user-or-llm',
364364
wandConfig: {
@@ -838,7 +838,7 @@ export const HarmonicBlockMeta = {
838838
description:
839839
'Turn LinkedIn URLs or email addresses a workflow already holds into Harmonic contacts.',
840840
content:
841-
'# 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.',
841+
'# 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.',
842842
},
843843
{
844844
name: 'source-company-employees',

apps/sim/tools/error-extractors.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
213213
{
214214
id: 'harmonic-errors',
215215
description:
216-
'Harmonic API message errors, string and object FastAPI detail aborts including the enrichment URN, and validation detail arrays without echoed request input',
216+
'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',
217217
examples: ['Harmonic'],
218218
extract: (errorInfo) => {
219219
const data = errorInfo?.data
@@ -241,12 +241,30 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
241241
if (data.detail && typeof data.detail === 'object' && !Array.isArray(data.detail)) {
242242
const detail = data.detail as { message?: unknown; enrichment_urn?: unknown }
243243
const detailMessage = typeof detail.message === 'string' ? detail.message.trim() : ''
244-
if (!detailMessage) return undefined
245244
const enrichmentUrn =
246245
typeof detail.enrichment_urn === 'string' ? detail.enrichment_urn.trim() : ''
246+
if (!detailMessage) return enrichmentUrn || undefined
247247
return enrichmentUrn ? `${detailMessage} (${enrichmentUrn})` : detailMessage
248248
}
249249

250+
/**
251+
* The bulk email-enrichment endpoint answers 422/429 with a code in `error`
252+
* and no message anywhere — `{error: 'MONTHLY_QUOTA_INSUFFICIENT', needed,
253+
* available, submitted}`. These are the most actionable failures on that path.
254+
*
255+
* Gated on one of the documented numeric counters being present. `error` alone
256+
* is far too common a key to claim: `extractErrorMessage` without an explicit
257+
* id walks every extractor in order, so a bare `error` check here would swallow
258+
* OAuth's `{error, error_description}` and return the code instead of the text.
259+
*/
260+
const emailJobCounters = (['needed', 'available', 'submitted'] as const).filter(
261+
(key) => typeof data[key] === 'number'
262+
)
263+
if (typeof data.error === 'string' && data.error.trim() && emailJobCounters.length > 0) {
264+
const code = data.error.trim()
265+
return `${code} (${emailJobCounters.map((key) => `${key} ${data[key]}`).join(', ')})`
266+
}
267+
250268
if (!Array.isArray(data.detail)) return undefined
251269
const details = data.detail
252270
.map((entry: unknown) => {

apps/sim/tools/harmonic/harmonic.test.ts

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ describe('Harmonic authentication and registry-facing contracts', () => {
230230
expect(headers.Authorization).toBeUndefined()
231231
}
232232

233+
/** One sample per registered tool: every URL builder interpolates user input. */
233234
const requestSamples: Array<[ToolConfig, Record<string, unknown>]> = [
234235
[harmonicSearchPeopleScoutTool, { accessToken: 'team-secret', query: 'find FDEs' }],
235236
[harmonicListPeopleSavedSearchesTool, { accessToken: 'team-secret' }],
@@ -238,7 +239,35 @@ describe('Harmonic authentication and registry-facing contracts', () => {
238239
{ accessToken: 'team-secret', savedSearchId: 'urn:harmonic:saved_search:1' },
239240
],
240241
[harmonicBatchGetPeopleTool, { accessToken: 'team-secret', personIds: [1] }],
242+
[
243+
harmonicEnrichPersonTool,
244+
{ accessToken: 'team-secret', linkedinUrl: 'https://www.linkedin.com/in/ada' },
245+
],
246+
[harmonicGetPersonTool, { accessToken: 'team-secret', personId: '123' }],
247+
[harmonicGetCompanyEmployeesTool, { accessToken: 'team-secret', companyId: '1' }],
248+
[
249+
harmonicGetPeopleSavedSearchNetNewResultsTool,
250+
{ accessToken: 'team-secret', savedSearchId: '5' },
251+
],
252+
[
253+
harmonicClearPeopleSavedSearchNetNewResultsTool,
254+
{ accessToken: 'team-secret', savedSearchId: '5', clearScope: 'all' },
255+
],
256+
[
257+
harmonicSubmitEmailEnrichmentJobTool,
258+
{ accessToken: 'team-secret', personUrns: ['urn:harmonic:person:1'] },
259+
],
260+
[harmonicGetEmailEnrichmentJobTool, { accessToken: 'team-secret', jobId: 'job-1' }],
261+
[harmonicGetEmailEnrichmentUsageTool, { accessToken: 'team-secret' }],
262+
[
263+
harmonicGetEnrichmentStatusTool,
264+
{ accessToken: 'team-secret', enrichmentUrns: ['urn:harmonic:enrichment:1'] },
265+
],
241266
]
267+
expect(requestSamples).toHaveLength(allTools.length)
268+
expect(new Set(requestSamples.map(([tool]) => tool.id))).toEqual(
269+
new Set(allTools.map((tool) => tool.id))
270+
)
242271
for (const [tool, params] of requestSamples) {
243272
expect(buildUrl(tool, params)).not.toContain('team-secret')
244273
if (tool.request.body)
@@ -336,6 +365,13 @@ describe('Harmonic authentication and registry-facing contracts', () => {
336365
{ status: 404, data: { detail: { enrichment_urn: 'urn:harmonic:enrichment:abc' } } },
337366
harmonicEnrichPersonTool.errorExtractor
338367
)
368+
).toBe('urn:harmonic:enrichment:abc')
369+
370+
expect(
371+
extractErrorMessage(
372+
{ status: 404, data: { detail: {} } },
373+
harmonicEnrichPersonTool.errorExtractor
374+
)
339375
).toBe('Request failed with status 404')
340376
})
341377

@@ -564,7 +600,6 @@ describe('Harmonic people retrieval', () => {
564600
['entity_urn', 'urn:harmonic:company:1'],
565601
['name', ' '],
566602
['creator', 'urn:harmonic:company:1'],
567-
['user_saved_search_type', 'UNKNOWN'],
568603
['created_at', 'yesterday'],
569604
['created_at', '2026-02-31T12:34:56Z'],
570605
['created_at', '2026-01-01T00:00:60Z'],
@@ -579,6 +614,14 @@ describe('Harmonic people retrieval', () => {
579614
).rejects.toThrow(/saved search/)
580615
})
581616

617+
it('passes an unrecognized user_saved_search_type through instead of failing the list', async () => {
618+
const result = await harmonicListPeopleSavedSearchesTool.transformResponse!(
619+
jsonResponse([{ ...validPeopleSavedSearch, user_saved_search_type: 'SOMETHING_NEW' }])
620+
)
621+
expect(result.output.savedSearches).toHaveLength(1)
622+
expect(result.output.savedSearches[0].userSavedSearchType).toBe('SOMETHING_NEW')
623+
})
624+
582625
it.each([
583626
'id',
584627
'entity_urn',
@@ -929,6 +972,15 @@ describe('Harmonic person enrichment', () => {
929972
}
930973
})
931974

975+
it('rejects company context URNs from another entity family', () => {
976+
expect(() =>
977+
buildUrl(harmonicGetPersonTool, {
978+
personId: '123',
979+
companyContextUrns: ['urn:harmonic:person:1'],
980+
})
981+
).toThrow('"companyContextUrns" must contain only company URNs')
982+
})
983+
932984
it('repeats company context URNs as query parameters', () => {
933985
expect(
934986
buildUrl(harmonicGetPersonTool, {
@@ -1098,6 +1150,116 @@ describe('Harmonic email enrichment', () => {
10981150
).toThrow('must contain absolute http(s) URLs')
10991151
})
11001152

1153+
it('folds every spelling of one profile into a single submitted entry', () => {
1154+
expect(
1155+
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
1156+
personLinkedinUrls: [
1157+
'https://www.linkedin.com/in/ada?utm_source=x',
1158+
'https://www.linkedin.com/in/ada',
1159+
'https://www.linkedin.com/in/ada#about',
1160+
'https://www.linkedin.com/in/ada/',
1161+
'https://linkedin.com/in/ada',
1162+
'https://WWW.LinkedIn.com/in/ada',
1163+
],
1164+
})
1165+
).toEqual({ person_linkedin_urls: ['https://www.linkedin.com/in/ada'] })
1166+
})
1167+
1168+
it('keeps pass-through URLs distinct on every component Harmonic still sees', () => {
1169+
expect(
1170+
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
1171+
personLinkedinUrls: [
1172+
'https://profiles.example/p?id=1',
1173+
'https://profiles.example/p?id=2',
1174+
'https://profiles.example:8443/p',
1175+
'https://profiles.example/p#a',
1176+
'https://profiles.example/p#b',
1177+
],
1178+
})
1179+
).toEqual({
1180+
person_linkedin_urls: [
1181+
'https://profiles.example/p?id=1',
1182+
'https://profiles.example/p?id=2',
1183+
'https://profiles.example:8443/p',
1184+
'https://profiles.example/p#a',
1185+
'https://profiles.example/p#b',
1186+
],
1187+
})
1188+
1189+
expect(
1190+
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
1191+
personLinkedinUrls: ['https://profiles.example/p?id=1', 'https://profiles.example/p?id=1'],
1192+
})
1193+
).toEqual({ person_linkedin_urls: ['https://profiles.example/p?id=1'] })
1194+
})
1195+
1196+
it('keeps regional subdomains distinct rather than assuming an undocumented equivalence', () => {
1197+
expect(
1198+
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
1199+
personLinkedinUrls: ['https://uk.linkedin.com/in/ada', 'https://www.linkedin.com/in/ada'],
1200+
})
1201+
).toEqual({
1202+
person_linkedin_urls: ['https://uk.linkedin.com/in/ada', 'https://www.linkedin.com/in/ada'],
1203+
})
1204+
})
1205+
1206+
it('treats blank LinkedIn entries as absent instead of a conflicting identifier list', () => {
1207+
expect(
1208+
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
1209+
personUrns: ['urn:harmonic:person:1'],
1210+
personLinkedinUrls: ['', ' ', null],
1211+
})
1212+
).toEqual({ person_urns: ['urn:harmonic:person:1'] })
1213+
1214+
expect(() =>
1215+
buildBody(harmonicSubmitEmailEnrichmentJobTool, { personLinkedinUrls: ['', ' '] })
1216+
).toThrow('requires at least one person URN or LinkedIn profile URL')
1217+
})
1218+
1219+
it('reports the identifier conflict before complaining about any single URL', () => {
1220+
expect(() =>
1221+
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
1222+
personUrns: ['urn:harmonic:person:1'],
1223+
personLinkedinUrls: ['not-a-url'],
1224+
})
1225+
).toThrow('accepts person URNs or LinkedIn URLs, not both')
1226+
})
1227+
1228+
it('surfaces the bulk email error codes with their quota counters', () => {
1229+
expect(
1230+
extractErrorMessage(
1231+
{
1232+
status: 429,
1233+
data: {
1234+
error: 'MONTHLY_QUOTA_INSUFFICIENT',
1235+
needed: 500,
1236+
available: 20,
1237+
submitted: 500,
1238+
},
1239+
},
1240+
harmonicSubmitEmailEnrichmentJobTool.errorExtractor
1241+
)
1242+
).toBe('MONTHLY_QUOTA_INSUFFICIENT (needed 500, available 20, submitted 500)')
1243+
1244+
expect(
1245+
extractErrorMessage(
1246+
{ status: 422, data: { error: 'NO_ELIGIBLE_PEOPLE', submitted: 3, dropped: [] } },
1247+
harmonicSubmitEmailEnrichmentJobTool.errorExtractor
1248+
)
1249+
).toBe('NO_ELIGIBLE_PEOPLE (submitted 3)')
1250+
1251+
/**
1252+
* `extractErrorMessage` with no id walks every extractor in order, so a bare
1253+
* `error` key here would hijack other providers' envelopes.
1254+
*/
1255+
expect(
1256+
extractErrorMessage({
1257+
status: 400,
1258+
data: { error: 'invalid_grant', error_description: 'The grant is invalid' },
1259+
})
1260+
).toBe('The grant is invalid')
1261+
})
1262+
11011263
it('forwards unrecognised profile URLs so Harmonic can drop them per item', () => {
11021264
expect(
11031265
buildBody(harmonicSubmitEmailEnrichmentJobTool, {

apps/sim/tools/harmonic/types.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -120,24 +120,6 @@ export interface HarmonicEnrichmentOutput {
120120
enriched_entity_urn?: unknown
121121
}
122122

123-
export interface HarmonicDroppedPerson {
124-
submitted_identifier?: unknown
125-
reason?: unknown
126-
}
127-
128-
export interface HarmonicPersonJobResultOutput {
129-
person_urn?: unknown
130-
status?: unknown
131-
}
132-
133-
export interface HarmonicPersonJobCountsOutput {
134-
total_processed?: unknown
135-
total_succeeded?: unknown
136-
total_failed?: unknown
137-
total_skipped?: unknown
138-
total_not_found?: unknown
139-
}
140-
141123
export interface HarmonicEnrichmentStatus {
142124
enrichmentUrn: string | null
143125
status: string | null

0 commit comments

Comments
 (0)