@coldiq/mcp 0.3.16 → 0.3.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tools/find-emails.d.ts +5 -3
- package/dist/tools/find-emails.d.ts.map +1 -1
- package/dist/tools/find-emails.js +42 -3
- package/dist/tools/find-emails.js.map +1 -1
- package/dist/tools/search-companies.js +2 -2
- package/dist/tools/search-companies.js.map +1 -1
- package/package.json +1 -1
- package/src/tools/find-emails.ts +55 -4
- package/src/tools/search-companies.ts +2 -2
- package/tests/tools/find-emails.test.ts +72 -0
|
@@ -23,11 +23,13 @@ export declare const findEmailsSchema: {
|
|
|
23
23
|
}>, "many">;
|
|
24
24
|
use_providers: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
25
25
|
};
|
|
26
|
-
|
|
26
|
+
type ToolResult = {
|
|
27
27
|
content: [{
|
|
28
|
-
type:
|
|
28
|
+
type: 'text';
|
|
29
29
|
text: string;
|
|
30
30
|
}];
|
|
31
31
|
isError?: boolean;
|
|
32
|
-
}
|
|
32
|
+
};
|
|
33
|
+
export declare function findEmailsHandler(input: Record<string, unknown>): Promise<ToolResult>;
|
|
34
|
+
export {};
|
|
33
35
|
//# sourceMappingURL=find-emails.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"find-emails.d.ts","sourceRoot":"","sources":["../../src/tools/find-emails.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAIvB,eAAO,MAAM,cAAc,gBAAgB,CAAA;AAE3C,eAAO,MAAM,qBAAqB,QAOqE,CAAA;AAEvG,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;CAe5B,CAAA;
|
|
1
|
+
{"version":3,"file":"find-emails.d.ts","sourceRoot":"","sources":["../../src/tools/find-emails.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAIvB,eAAO,MAAM,cAAc,gBAAgB,CAAA;AAE3C,eAAO,MAAM,qBAAqB,QAOqE,CAAA;AAEvG,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;CAe5B,CAAA;AASD,KAAK,UAAU,GAAG;IAAE,OAAO,EAAE,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,CAAA;AAMlF,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,CA0C3F"}
|
|
@@ -23,9 +23,48 @@ export const findEmailsSchema = {
|
|
|
23
23
|
.describe('People to find emails for (max 50)'),
|
|
24
24
|
use_providers: z.array(z.string()).optional().describe(`Optional ordered list of providers to use. Leave empty to let ColdIQ automatically run the best waterfall — recommended for most use cases. Available providers: ${FIND_EMAILS_PROVIDERS.join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
|
|
25
25
|
};
|
|
26
|
+
// Split large batches into sequential sub-batches so each HTTP call stays well
|
|
27
|
+
// under the server's bulk wall-clock budget (and thus Cloudflare's ~100s edge
|
|
28
|
+
// cutoff). A single 50-contact call could run long enough to be reset ("Network
|
|
29
|
+
// error", WF2); two ~25-contact calls each return quickly. Matches the server's
|
|
30
|
+
// bulk concurrency cap so each chunk is a single server wave.
|
|
31
|
+
const FIND_EMAILS_CHUNK_SIZE = 25;
|
|
32
|
+
function toolResult(payload, isError) {
|
|
33
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload) }], isError };
|
|
34
|
+
}
|
|
26
35
|
export async function findEmailsHandler(input) {
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
36
|
+
const people = Array.isArray(input.people) ? input.people : [];
|
|
37
|
+
// Small batch (the common case) — one call, unchanged. Bulk verb returns results
|
|
38
|
+
// positionally with no id echo, so re-attach each person's `id` to match back.
|
|
39
|
+
if (people.length <= FIND_EMAILS_CHUNK_SIZE) {
|
|
40
|
+
return callVerb('/email/find', input, { bulkField: 'people', reattachIdField: 'id' });
|
|
41
|
+
}
|
|
42
|
+
// Large batch — chunk sequentially and merge the per-contact results in order.
|
|
43
|
+
const merged = [];
|
|
44
|
+
for (let i = 0; i < people.length; i += FIND_EMAILS_CHUNK_SIZE) {
|
|
45
|
+
const chunk = people.slice(i, i + FIND_EMAILS_CHUNK_SIZE);
|
|
46
|
+
const res = (await callVerb('/email/find', { ...input, people: chunk }, { bulkField: 'people', reattachIdField: 'id' }));
|
|
47
|
+
let data;
|
|
48
|
+
try {
|
|
49
|
+
data = JSON.parse(res.content[0].text);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
data = undefined;
|
|
53
|
+
}
|
|
54
|
+
const results = data?.results;
|
|
55
|
+
if (!Array.isArray(results)) {
|
|
56
|
+
// Whole-chunk failure (bad request, auth, …) — surface it alongside whatever
|
|
57
|
+
// we already gathered. Spread errorBody first so the accumulated `results`
|
|
58
|
+
// always win, even if the error body carries its own (non-array) results key.
|
|
59
|
+
const errorBody = data && typeof data === 'object' ? data : { error: 'find_emails request failed' };
|
|
60
|
+
return toolResult({ ...errorBody, results: merged }, true);
|
|
61
|
+
}
|
|
62
|
+
merged.push(...results);
|
|
63
|
+
// A transport/billing error on a chunk (e.g. 402 out of credits) is terminal —
|
|
64
|
+
// stop rather than burning the remaining chunks.
|
|
65
|
+
if (res.isError)
|
|
66
|
+
return toolResult({ results: merged }, true);
|
|
67
|
+
}
|
|
68
|
+
return toolResult({ results: merged }, false);
|
|
30
69
|
}
|
|
31
70
|
//# sourceMappingURL=find-emails.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"find-emails.js","sourceRoot":"","sources":["../../src/tools/find-emails.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AAC5C,OAAO,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAA;AAErE,MAAM,CAAC,MAAM,cAAc,GAAG,aAAa,CAAA;AAE3C,MAAM,CAAC,MAAM,qBAAqB,GAChC,6DAA6D;IAC7D,mEAAmE;IACnE,0EAA0E;IAC1E,uDAAuD;IACvD,0FAA0F;IAC1F,kHAAkH;IAClH,qGAAqG,CAAA;AAEvG,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC9B,MAAM,EAAE,CAAC;SACN,KAAK,CACJ,CAAC,CAAC,MAAM,CAAC;QACP,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,wEAAwE,CAAC;QACjG,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,YAAY,CAAC;QACxD,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,WAAW,CAAC;QACtD,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oCAAoC,CAAC;QAC5E,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,+EAA+E,CAAC;KAC9H,CAAC,CACH;SACA,GAAG,CAAC,CAAC,CAAC;SACN,GAAG,CAAC,EAAE,CAAC;SACP,QAAQ,CAAC,oCAAoC,CAAC;IACjD,aAAa,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,oKAAoK,qBAAqB,CAAC,IAAI,CAAC,IAAI,CAAC,qEAAqE,CAAC;CAClU,CAAA;AAED,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,KAA8B;IACpE,
|
|
1
|
+
{"version":3,"file":"find-emails.js","sourceRoot":"","sources":["../../src/tools/find-emails.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { callVerb } from '../verb-client.js';
|
|
3
3
|
import { getProvidersForCapability } from '../utils/provider-resolver.js';
|
|
4
4
|
export const searchCompaniesName = 'search_companies';
|
|
5
|
-
export const searchCompaniesDescription = 'Search B2B companies by industry, size, geography, founding year, tech stack, funding, revenue, exclusion lists, and hiring signals. Routing is automatic: strict firmographic filters (revenue, employee bands, exclusion lists) get high-precision providers first; tech-stack and funding-stage filters route to specialized providers; loose keyword/geo searches fall back to broad providers; a LinkedIn Sales Navigator search URL enables URL-based discovery as a final fallback. For buying-intent topic discovery (find companies associated with intent keywords like "sales automation" or "lead generation"), pass `keywords` with `use_providers: ["theirstack"]` — this routes to TheirStack\'s intent-keyword index instead of the default full-text match. ColdIQ automatically picks the best provider — pass use_providers only if you need a specific tool. Default limit: 25. ' +
|
|
5
|
+
export const searchCompaniesDescription = 'Search B2B companies by industry, size, geography, founding year, tech stack, funding, revenue, exclusion lists, and hiring signals. Routing is automatic: strict firmographic filters (revenue, employee bands, exclusion lists) get high-precision providers first; tech-stack and funding-stage filters route to specialized providers; loose keyword/geo searches fall back to broad providers; a LinkedIn Sales Navigator search URL enables URL-based discovery as a final fallback. For buying-intent topic discovery (find companies associated with intent keywords like "sales automation" or "lead generation"), pass `keywords` with `use_providers: ["theirstack"]` — this routes to TheirStack\'s intent-keyword index instead of the default full-text match. For "fast-growing" companies there is no exact workforce-growth % filter anywhere — pass min_workforce_growth_pct (and/or is_hiring / a recent-funding filter) and ColdIQ routes to active-hiring and recently-funded providers, the practical proxies for growth. ColdIQ automatically picks the best provider — pass use_providers only if you need a specific tool. Default limit: 25. ' +
|
|
6
6
|
'Response is compact by default (name, domain, website, linkedin_url, employees, revenue, industry, country, city, founded_year, categories) — everything needed to act on a company or feed it into find_people. Set fields="verbose" only when you specifically need the full firmographic record (funding history, technologies, keywords, NAICS codes, all socials, descriptions).';
|
|
7
7
|
export const searchCompaniesSchema = {
|
|
8
8
|
keywords: z.array(z.string()).optional().describe('Free-text topics, business models, or themes — e.g. ["SaaS", "fintech", "cybersecurity"]. Best for most discovery: matched broadly across company name, description, and tags. Prefer this over `industries` for terms like "SaaS" that are business models rather than formal industry categories.'),
|
|
@@ -25,7 +25,7 @@ export const searchCompaniesSchema = {
|
|
|
25
25
|
exclude_industries: z.array(z.string()).optional().describe('Industry names to exclude from results'),
|
|
26
26
|
exclude_countries: z.array(z.string()).optional().describe('2-letter ISO country codes to exclude from results'),
|
|
27
27
|
is_hiring: z.boolean().optional().describe('Filter to companies currently posting jobs'),
|
|
28
|
-
min_workforce_growth_pct: z.number().optional().describe('
|
|
28
|
+
min_workforce_growth_pct: z.number().optional().describe('Signals "fast-growing" intent. No provider applies an exact growth %, so this routes the search to active-hiring and recently-funded proxies rather than filtering on a literal percentage.'),
|
|
29
29
|
linkedin_search_url: z.string().optional().describe('LinkedIn Sales Navigator company search URL — when provided, enables URL-based prospect discovery as a final fallback. Most users should leave this unset.'),
|
|
30
30
|
limit: z.number().min(1).max(500).default(25).describe('Max results to return (default: 25, max: 500). Pulls above 100 auto-paginate the underlying provider; on Apollo each 100 companies costs 1 credit, billed only for pages actually returned.'),
|
|
31
31
|
fields: z.enum(['compact', 'verbose']).default('compact').describe('Response shape. "compact" (default) returns a small per-company record: name, domain, website, linkedin_url, employees, revenue, industry, country, city, founded_year, categories — plus total. "verbose" returns the raw provider passthrough (funding-round history, technologies, keyword arrays, NAICS codes, all socials, descriptions) — only use when you specifically need those fields.'),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search-companies.js","sourceRoot":"","sources":["../../src/tools/search-companies.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AAC5C,OAAO,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAA;AAEzE,MAAM,CAAC,MAAM,mBAAmB,GAAG,kBAAkB,CAAA;AAErD,MAAM,CAAC,MAAM,0BAA0B,GACrC,
|
|
1
|
+
{"version":3,"file":"search-companies.js","sourceRoot":"","sources":["../../src/tools/search-companies.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/tools/find-emails.ts
CHANGED
|
@@ -30,8 +30,59 @@ export const findEmailsSchema = {
|
|
|
30
30
|
use_providers: z.array(z.string()).optional().describe(`Optional ordered list of providers to use. Leave empty to let ColdIQ automatically run the best waterfall — recommended for most use cases. Available providers: ${FIND_EMAILS_PROVIDERS.join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
// Split large batches into sequential sub-batches so each HTTP call stays well
|
|
34
|
+
// under the server's bulk wall-clock budget (and thus Cloudflare's ~100s edge
|
|
35
|
+
// cutoff). A single 50-contact call could run long enough to be reset ("Network
|
|
36
|
+
// error", WF2); two ~25-contact calls each return quickly. Matches the server's
|
|
37
|
+
// bulk concurrency cap so each chunk is a single server wave.
|
|
38
|
+
const FIND_EMAILS_CHUNK_SIZE = 25
|
|
39
|
+
|
|
40
|
+
type ToolResult = { content: [{ type: 'text'; text: string }]; isError?: boolean }
|
|
41
|
+
|
|
42
|
+
function toolResult(payload: unknown, isError: boolean): ToolResult {
|
|
43
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload) }], isError }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function findEmailsHandler(input: Record<string, unknown>): Promise<ToolResult> {
|
|
47
|
+
const people = Array.isArray(input.people) ? (input.people as unknown[]) : []
|
|
48
|
+
|
|
49
|
+
// Small batch (the common case) — one call, unchanged. Bulk verb returns results
|
|
50
|
+
// positionally with no id echo, so re-attach each person's `id` to match back.
|
|
51
|
+
if (people.length <= FIND_EMAILS_CHUNK_SIZE) {
|
|
52
|
+
return callVerb('/email/find', input, { bulkField: 'people', reattachIdField: 'id' }) as Promise<ToolResult>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Large batch — chunk sequentially and merge the per-contact results in order.
|
|
56
|
+
const merged: unknown[] = []
|
|
57
|
+
for (let i = 0; i < people.length; i += FIND_EMAILS_CHUNK_SIZE) {
|
|
58
|
+
const chunk = people.slice(i, i + FIND_EMAILS_CHUNK_SIZE)
|
|
59
|
+
const res = (await callVerb(
|
|
60
|
+
'/email/find',
|
|
61
|
+
{ ...input, people: chunk },
|
|
62
|
+
{ bulkField: 'people', reattachIdField: 'id' },
|
|
63
|
+
)) as ToolResult
|
|
64
|
+
|
|
65
|
+
let data: unknown
|
|
66
|
+
try {
|
|
67
|
+
data = JSON.parse(res.content[0].text)
|
|
68
|
+
} catch {
|
|
69
|
+
data = undefined
|
|
70
|
+
}
|
|
71
|
+
const results = (data as { results?: unknown[] } | undefined)?.results
|
|
72
|
+
|
|
73
|
+
if (!Array.isArray(results)) {
|
|
74
|
+
// Whole-chunk failure (bad request, auth, …) — surface it alongside whatever
|
|
75
|
+
// we already gathered. Spread errorBody first so the accumulated `results`
|
|
76
|
+
// always win, even if the error body carries its own (non-array) results key.
|
|
77
|
+
const errorBody = data && typeof data === 'object' ? (data as object) : { error: 'find_emails request failed' }
|
|
78
|
+
return toolResult({ ...errorBody, results: merged }, true)
|
|
79
|
+
}
|
|
80
|
+
merged.push(...results)
|
|
81
|
+
|
|
82
|
+
// A transport/billing error on a chunk (e.g. 402 out of credits) is terminal —
|
|
83
|
+
// stop rather than burning the remaining chunks.
|
|
84
|
+
if (res.isError) return toolResult({ results: merged }, true)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return toolResult({ results: merged }, false)
|
|
37
88
|
}
|
|
@@ -5,7 +5,7 @@ import { getProvidersForCapability } from '../utils/provider-resolver.js'
|
|
|
5
5
|
export const searchCompaniesName = 'search_companies'
|
|
6
6
|
|
|
7
7
|
export const searchCompaniesDescription =
|
|
8
|
-
'Search B2B companies by industry, size, geography, founding year, tech stack, funding, revenue, exclusion lists, and hiring signals. Routing is automatic: strict firmographic filters (revenue, employee bands, exclusion lists) get high-precision providers first; tech-stack and funding-stage filters route to specialized providers; loose keyword/geo searches fall back to broad providers; a LinkedIn Sales Navigator search URL enables URL-based discovery as a final fallback. For buying-intent topic discovery (find companies associated with intent keywords like "sales automation" or "lead generation"), pass `keywords` with `use_providers: ["theirstack"]` — this routes to TheirStack\'s intent-keyword index instead of the default full-text match. ColdIQ automatically picks the best provider — pass use_providers only if you need a specific tool. Default limit: 25. ' +
|
|
8
|
+
'Search B2B companies by industry, size, geography, founding year, tech stack, funding, revenue, exclusion lists, and hiring signals. Routing is automatic: strict firmographic filters (revenue, employee bands, exclusion lists) get high-precision providers first; tech-stack and funding-stage filters route to specialized providers; loose keyword/geo searches fall back to broad providers; a LinkedIn Sales Navigator search URL enables URL-based discovery as a final fallback. For buying-intent topic discovery (find companies associated with intent keywords like "sales automation" or "lead generation"), pass `keywords` with `use_providers: ["theirstack"]` — this routes to TheirStack\'s intent-keyword index instead of the default full-text match. For "fast-growing" companies there is no exact workforce-growth % filter anywhere — pass min_workforce_growth_pct (and/or is_hiring / a recent-funding filter) and ColdIQ routes to active-hiring and recently-funded providers, the practical proxies for growth. ColdIQ automatically picks the best provider — pass use_providers only if you need a specific tool. Default limit: 25. ' +
|
|
9
9
|
'Response is compact by default (name, domain, website, linkedin_url, employees, revenue, industry, country, city, founded_year, categories) — everything needed to act on a company or feed it into find_people. Set fields="verbose" only when you specifically need the full firmographic record (funding history, technologies, keywords, NAICS codes, all socials, descriptions).'
|
|
10
10
|
|
|
11
11
|
export const searchCompaniesSchema = {
|
|
@@ -29,7 +29,7 @@ export const searchCompaniesSchema = {
|
|
|
29
29
|
exclude_industries: z.array(z.string()).optional().describe('Industry names to exclude from results'),
|
|
30
30
|
exclude_countries: z.array(z.string()).optional().describe('2-letter ISO country codes to exclude from results'),
|
|
31
31
|
is_hiring: z.boolean().optional().describe('Filter to companies currently posting jobs'),
|
|
32
|
-
min_workforce_growth_pct: z.number().optional().describe('
|
|
32
|
+
min_workforce_growth_pct: z.number().optional().describe('Signals "fast-growing" intent. No provider applies an exact growth %, so this routes the search to active-hiring and recently-funded proxies rather than filtering on a literal percentage.'),
|
|
33
33
|
linkedin_search_url: z.string().optional().describe('LinkedIn Sales Navigator company search URL — when provided, enables URL-based prospect discovery as a final fallback. Most users should leave this unset.'),
|
|
34
34
|
limit: z.number().min(1).max(500).default(25).describe('Max results to return (default: 25, max: 500). Pulls above 100 auto-paginate the underlying provider; on Apollo each 100 companies costs 1 credit, billed only for pages actually returned.'),
|
|
35
35
|
fields: z.enum(['compact', 'verbose']).default('compact').describe('Response shape. "compact" (default) returns a small per-company record: name, domain, website, linkedin_url, employees, revenue, industry, country, city, founded_year, categories — plus total. "verbose" returns the raw provider passthrough (funding-round history, technologies, keyword arrays, NAICS codes, all socials, descriptions) — only use when you specifically need those fields.'),
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// Mock the verb client so we test the handler's chunk/merge logic directly,
|
|
4
|
+
// without building real envelopes or touching the network.
|
|
5
|
+
const callVerbMock = vi.fn()
|
|
6
|
+
vi.mock('../../src/verb-client.js', () => ({
|
|
7
|
+
callVerb: (...args: unknown[]) => callVerbMock(...args),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
import { findEmailsHandler } from '../../src/tools/find-emails.js'
|
|
11
|
+
|
|
12
|
+
type Person = { id: string; domain?: string }
|
|
13
|
+
const peopleOf = (n: number): Person[] => Array.from({ length: n }, (_, i) => ({ id: `p${i}`, domain: 'coldiq.com' }))
|
|
14
|
+
|
|
15
|
+
// Build the ToolResult callVerb would return: one result per person in the chunk.
|
|
16
|
+
function chunkResult(input: Record<string, unknown>, opts?: { isError?: boolean }) {
|
|
17
|
+
const people = (input.people as Person[]) ?? []
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: 'text' as const, text: JSON.stringify({ results: people.map((p) => ({ id: p.id, email: `${p.id}@x.com` })) }) }],
|
|
20
|
+
isError: opts?.isError ?? false,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
callVerbMock.mockReset()
|
|
26
|
+
callVerbMock.mockImplementation((_path: string, input: Record<string, unknown>) => Promise.resolve(chunkResult(input)))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('find_emails auto-chunking', () => {
|
|
30
|
+
it('≤25 people → a single passthrough call', async () => {
|
|
31
|
+
await findEmailsHandler({ people: peopleOf(25) })
|
|
32
|
+
expect(callVerbMock).toHaveBeenCalledTimes(1)
|
|
33
|
+
expect((callVerbMock.mock.calls[0][1] as { people: Person[] }).people).toHaveLength(25)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('>25 people → sequential chunks of ≤25, merged in order', async () => {
|
|
37
|
+
const res = await findEmailsHandler({ people: peopleOf(60) })
|
|
38
|
+
|
|
39
|
+
// 60 → 25 + 25 + 10
|
|
40
|
+
expect(callVerbMock).toHaveBeenCalledTimes(3)
|
|
41
|
+
expect((callVerbMock.mock.calls[0][1] as { people: Person[] }).people).toHaveLength(25)
|
|
42
|
+
expect((callVerbMock.mock.calls[1][1] as { people: Person[] }).people).toHaveLength(25)
|
|
43
|
+
expect((callVerbMock.mock.calls[2][1] as { people: Person[] }).people).toHaveLength(10)
|
|
44
|
+
|
|
45
|
+
const data = JSON.parse(res.content[0].text)
|
|
46
|
+
expect(data.results).toHaveLength(60)
|
|
47
|
+
// Order preserved across chunks.
|
|
48
|
+
expect(data.results.map((r: { id: string }) => r.id)).toEqual(peopleOf(60).map((p) => p.id))
|
|
49
|
+
expect(res.isError).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('forwards use_providers to every chunk', async () => {
|
|
53
|
+
await findEmailsHandler({ people: peopleOf(30), use_providers: ['limadata-work-email'] })
|
|
54
|
+
expect(callVerbMock).toHaveBeenCalledTimes(2)
|
|
55
|
+
for (const call of callVerbMock.mock.calls) {
|
|
56
|
+
expect((call[1] as { use_providers: string[] }).use_providers).toEqual(['limadata-work-email'])
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('stops early and surfaces the error when a chunk fails terminally (e.g. 402)', async () => {
|
|
61
|
+
callVerbMock
|
|
62
|
+
.mockImplementationOnce((_p: string, input: Record<string, unknown>) => Promise.resolve(chunkResult(input)))
|
|
63
|
+
.mockImplementationOnce((_p: string, input: Record<string, unknown>) => Promise.resolve(chunkResult(input, { isError: true })))
|
|
64
|
+
const res = await findEmailsHandler({ people: peopleOf(60) })
|
|
65
|
+
|
|
66
|
+
// 1st chunk ok, 2nd is a terminal error → stop (3rd chunk never sent).
|
|
67
|
+
expect(callVerbMock).toHaveBeenCalledTimes(2)
|
|
68
|
+
expect(res.isError).toBe(true)
|
|
69
|
+
const data = JSON.parse(res.content[0].text)
|
|
70
|
+
expect(data.results).toHaveLength(50) // partial results preserved
|
|
71
|
+
})
|
|
72
|
+
})
|