@coldiq/mcp 0.3.17 → 0.3.19

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.
@@ -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
- export declare function findEmailsHandler(input: Record<string, unknown>): Promise<{
26
+ type ToolResult = {
27
27
  content: [{
28
- type: "text";
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;AAED,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;;;;;GAIrE"}
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
- // Bulk verb returns results positionally with no id echo — re-attach each
28
- // person's `id` so callers can still match results back.
29
- return callVerb('/email/find', input, { bulkField: 'people', reattachIdField: 'id' });
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,0EAA0E;IAC1E,yDAAyD;IACzD,OAAO,QAAQ,CAAC,aAAa,EAAE,KAAK,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE,eAAe,EAAE,IAAI,EAAE,CAAC,CAAA;AACvF,CAAC"}
1
+ {"version":3,"file":"find-emails.js","sourceRoot":"","sources":["../../src/tools/find-emails.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"get-session-usage.d.ts","sourceRoot":"","sources":["../../src/tools/get-session-usage.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,mBAAmB,sBAAsB,CAAA;AAEtD,eAAO,MAAM,0BAA0B,QAMuB,CAAA;AAE9D,eAAO,MAAM,qBAAqB,IAAK,CAAA;AAEvC,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;;;;GAG3E"}
1
+ {"version":3,"file":"get-session-usage.d.ts","sourceRoot":"","sources":["../../src/tools/get-session-usage.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,mBAAmB,sBAAsB,CAAA;AAEtD,eAAO,MAAM,0BAA0B,QAOY,CAAA;AAEnD,eAAO,MAAM,qBAAqB,IAAK,CAAA;AAMvC,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;;;;GA8B3E"}
@@ -1,14 +1,45 @@
1
1
  import { getSessionUsage } from '../session-usage.js';
2
+ import { callApi } from '../client.js';
2
3
  export const getSessionUsageName = 'get_session_usage';
3
- export const getSessionUsageDescription = 'Report the ColdIQ credits spent so far in THIS conversation (the current MCP session). ' +
4
- 'Returns the total credits charged, the number of billed calls, a per-endpoint breakdown of where ' +
5
- 'credits went (highest spend first), and the account balance reported after the most recent billed call. ' +
6
- 'Use this to answer "how many credits has this chat used" or "what did I spend credits on here". ' +
7
- 'Note: it counts only calls made during the current session and resets if the conversation restarts; ' +
8
- 'for the authoritative live balance use get_credit_balance.';
4
+ export const getSessionUsageDescription = 'Report the ColdIQ credits spent so far in this MCP process: total credits charged, billed-call count, ' +
5
+ 'and a per-endpoint breakdown (highest spend first), plus the live authoritative balance for reconciliation. ' +
6
+ 'The session tally is BEST-EFFORT it sums what each call reported at request time and therefore: (a) cannot ' +
7
+ 'see out-of-band async settlement (webhook refunds/charges that land after the call returns), (b) excludes ' +
8
+ 'transport failures that never reached the server, and (c) persists for the life of the MCP process, so a ' +
9
+ 'reused process can include earlier conversations. Treat session_spend as indicative; use authoritative_balance ' +
10
+ '(or get_credit_balance) as the source of truth.';
9
11
  export const getSessionUsageSchema = {};
12
+ const SESSION_USAGE_NOTE = 'session_spend is a best-effort, header-derived tally for this MCP process; it excludes out-of-band async ' +
13
+ 'settlement and resets only when the process restarts. Reconcile against authoritative_balance (live).';
10
14
  export async function getSessionUsageHandler(_input) {
11
15
  const usage = getSessionUsage();
12
- return { content: [{ type: 'text', text: JSON.stringify({ data: usage }) }] };
16
+ // Reconcile against the authoritative balance. /me/credits is not billed, so this
17
+ // call never pollutes the tally. Best-effort: a failure just omits the field.
18
+ let authoritativeBalance;
19
+ try {
20
+ const res = await callApi('GET', '/me/credits');
21
+ if (res.ok && res.data && typeof res.data === 'object') {
22
+ const bal = res.data.data?.balance;
23
+ if (typeof bal === 'number')
24
+ authoritativeBalance = bal;
25
+ }
26
+ }
27
+ catch {
28
+ // ignore — reconciliation is best-effort
29
+ }
30
+ return {
31
+ content: [
32
+ {
33
+ type: 'text',
34
+ text: JSON.stringify({
35
+ data: {
36
+ ...usage,
37
+ ...(authoritativeBalance !== undefined ? { authoritative_balance: authoritativeBalance } : {}),
38
+ note: SESSION_USAGE_NOTE,
39
+ },
40
+ }),
41
+ },
42
+ ],
43
+ };
13
44
  }
14
45
  //# sourceMappingURL=get-session-usage.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"get-session-usage.js","sourceRoot":"","sources":["../../src/tools/get-session-usage.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAA;AAErD,MAAM,CAAC,MAAM,mBAAmB,GAAG,mBAAmB,CAAA;AAEtD,MAAM,CAAC,MAAM,0BAA0B,GACrC,yFAAyF;IACzF,mGAAmG;IACnG,0GAA0G;IAC1G,kGAAkG;IAClG,sGAAsG;IACtG,4DAA4D,CAAA;AAE9D,MAAM,CAAC,MAAM,qBAAqB,GAAG,EAAE,CAAA;AAEvC,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAAC,MAA+B;IAC1E,MAAM,KAAK,GAAG,eAAe,EAAE,CAAA;IAC/B,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAe,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,CAAA;AACxF,CAAC"}
1
+ {"version":3,"file":"get-session-usage.js","sourceRoot":"","sources":["../../src/tools/get-session-usage.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAEtC,MAAM,CAAC,MAAM,mBAAmB,GAAG,mBAAmB,CAAA;AAEtD,MAAM,CAAC,MAAM,0BAA0B,GACrC,wGAAwG;IACxG,8GAA8G;IAC9G,+GAA+G;IAC/G,4GAA4G;IAC5G,2GAA2G;IAC3G,iHAAiH;IACjH,iDAAiD,CAAA;AAEnD,MAAM,CAAC,MAAM,qBAAqB,GAAG,EAAE,CAAA;AAEvC,MAAM,kBAAkB,GACtB,2GAA2G;IAC3G,uGAAuG,CAAA;AAEzG,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAAC,MAA+B;IAC1E,MAAM,KAAK,GAAG,eAAe,EAAE,CAAA;IAE/B,kFAAkF;IAClF,8EAA8E;IAC9E,IAAI,oBAAwC,CAAA;IAC5C,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,KAAK,EAAE,aAAa,CAAC,CAAA;QAC/C,IAAI,GAAG,CAAC,EAAE,IAAI,GAAG,CAAC,IAAI,IAAI,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACvD,MAAM,GAAG,GAAI,GAAG,CAAC,IAAyC,CAAC,IAAI,EAAE,OAAO,CAAA;YACxE,IAAI,OAAO,GAAG,KAAK,QAAQ;gBAAE,oBAAoB,GAAG,GAAG,CAAA;QACzD,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,yCAAyC;IAC3C,CAAC;IAED,OAAO;QACL,OAAO,EAAE;YACP;gBACE,IAAI,EAAE,MAAe;gBACrB,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;oBACnB,IAAI,EAAE;wBACJ,GAAG,KAAK;wBACR,GAAG,CAAC,oBAAoB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;wBAC9F,IAAI,EAAE,kBAAkB;qBACzB;iBACF,CAAC;aACH;SACF;KACF,CAAA;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coldiq/mcp",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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
- export async function findEmailsHandler(input: Record<string, unknown>) {
34
- // Bulk verb returns results positionally with no id echo re-attach each
35
- // person's `id` so callers can still match results back.
36
- return callVerb('/email/find', input, { bulkField: 'people', reattachIdField: 'id' })
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
  }
@@ -1,18 +1,51 @@
1
1
  import { getSessionUsage } from '../session-usage.js'
2
+ import { callApi } from '../client.js'
2
3
 
3
4
  export const getSessionUsageName = 'get_session_usage'
4
5
 
5
6
  export const getSessionUsageDescription =
6
- 'Report the ColdIQ credits spent so far in THIS conversation (the current MCP session). ' +
7
- 'Returns the total credits charged, the number of billed calls, a per-endpoint breakdown of where ' +
8
- 'credits went (highest spend first), and the account balance reported after the most recent billed call. ' +
9
- 'Use this to answer "how many credits has this chat used" or "what did I spend credits on here". ' +
10
- 'Note: it counts only calls made during the current session and resets if the conversation restarts; ' +
11
- 'for the authoritative live balance use get_credit_balance.'
7
+ 'Report the ColdIQ credits spent so far in this MCP process: total credits charged, billed-call count, ' +
8
+ 'and a per-endpoint breakdown (highest spend first), plus the live authoritative balance for reconciliation. ' +
9
+ 'The session tally is BEST-EFFORT it sums what each call reported at request time and therefore: (a) cannot ' +
10
+ 'see out-of-band async settlement (webhook refunds/charges that land after the call returns), (b) excludes ' +
11
+ 'transport failures that never reached the server, and (c) persists for the life of the MCP process, so a ' +
12
+ 'reused process can include earlier conversations. Treat session_spend as indicative; use authoritative_balance ' +
13
+ '(or get_credit_balance) as the source of truth.'
12
14
 
13
15
  export const getSessionUsageSchema = {}
14
16
 
17
+ const SESSION_USAGE_NOTE =
18
+ 'session_spend is a best-effort, header-derived tally for this MCP process; it excludes out-of-band async ' +
19
+ 'settlement and resets only when the process restarts. Reconcile against authoritative_balance (live).'
20
+
15
21
  export async function getSessionUsageHandler(_input: Record<string, unknown>) {
16
22
  const usage = getSessionUsage()
17
- return { content: [{ type: 'text' as const, text: JSON.stringify({ data: usage }) }] }
23
+
24
+ // Reconcile against the authoritative balance. /me/credits is not billed, so this
25
+ // call never pollutes the tally. Best-effort: a failure just omits the field.
26
+ let authoritativeBalance: number | undefined
27
+ try {
28
+ const res = await callApi('GET', '/me/credits')
29
+ if (res.ok && res.data && typeof res.data === 'object') {
30
+ const bal = (res.data as { data?: { balance?: unknown } }).data?.balance
31
+ if (typeof bal === 'number') authoritativeBalance = bal
32
+ }
33
+ } catch {
34
+ // ignore — reconciliation is best-effort
35
+ }
36
+
37
+ return {
38
+ content: [
39
+ {
40
+ type: 'text' as const,
41
+ text: JSON.stringify({
42
+ data: {
43
+ ...usage,
44
+ ...(authoritativeBalance !== undefined ? { authoritative_balance: authoritativeBalance } : {}),
45
+ note: SESSION_USAGE_NOTE,
46
+ },
47
+ }),
48
+ },
49
+ ],
50
+ }
18
51
  }
@@ -6,6 +6,7 @@ import {
6
6
  resetSessionUsage,
7
7
  } from '../src/session-usage.js'
8
8
  import { initClient, callApi } from '../src/client.js'
9
+ import { getSessionUsageHandler } from '../src/tools/get-session-usage.js'
9
10
 
10
11
  describe('session-usage accumulator', () => {
11
12
  beforeEach(() => {
@@ -128,3 +129,34 @@ describe('callApi records credit charges into session usage', () => {
128
129
  expect(getSessionUsage().billedCalls).toBe(0)
129
130
  })
130
131
  })
132
+
133
+ // --- B2: get_session_usage reconciles against the authoritative balance ------
134
+ describe('get_session_usage handler reconciliation (B2)', () => {
135
+ const originalFetch = globalThis.fetch
136
+ beforeEach(() => {
137
+ initClient('http://test-api.local', 'test-key')
138
+ resetSessionUsage(0)
139
+ })
140
+ afterEach(() => { globalThis.fetch = originalFetch })
141
+
142
+ it('attaches the live authoritative_balance and a best-effort note', async () => {
143
+ recordCharge('/email/find', 2, 500) // header-derived tally (may drift from real balance)
144
+ globalThis.fetch = vi.fn(async () =>
145
+ new Response(JSON.stringify({ data: { balance: 480.5, usedThisMonth: 20 } }), { status: 200 }),
146
+ ) as typeof fetch
147
+
148
+ const res = await getSessionUsageHandler({})
149
+ const payload = JSON.parse(res.content[0].text).data
150
+ expect(payload.totalCreditsCharged).toBe(2)
151
+ expect(payload.authoritative_balance).toBe(480.5) // reconciliation source of truth
152
+ expect(payload.note).toMatch(/best-effort/i)
153
+ })
154
+
155
+ it('still returns the tally + note when the balance lookup fails (best-effort)', async () => {
156
+ globalThis.fetch = vi.fn(async () => new Response('nope', { status: 502 })) as typeof fetch
157
+ const res = await getSessionUsageHandler({})
158
+ const payload = JSON.parse(res.content[0].text).data
159
+ expect(payload.authoritative_balance).toBeUndefined()
160
+ expect(payload.note).toBeTruthy()
161
+ })
162
+ })
@@ -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
+ })