diff --git a/AGENTS.md b/AGENTS.md index 04a3a8fb..21986a4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,10 +34,13 @@ high-priority (P0/P1) issues: - **New user-facing tools must be wired completely.** They must declare `routing` and `rendering_hint` frontmatter (plus `next_steps` when the - tool has a NEXT STEPS table), be added to `TOOLS_WITH_ROUTING` in - `packages/mcp/test/audit/routing-block.test.ts`, and carry ≥2 positive - AND ≥2 negative routing examples. Every `route_to` anti-trigger must - resolve to a registered tool name. + tool has a NEXT STEPS table), have the routing contract asserted for + them in a NEW audit file (never by appending to `TOOLS_WITH_ROUTING` in + the existing `packages/mcp/test/audit/routing-block.test.ts` — the + new-coverage-in-new-files rule above wins; see + `packages/mcp/test/audit/lead-delivery-routing-block.test.ts`), and + carry ≥2 positive AND ≥2 negative routing examples. Every `route_to` + anti-trigger must resolve to a registered tool name. - **`WORKFLOWS.md` is normative.** A new user story needs a row, and every backtick-wrapped `leadbay_*` identifier must resolve to a diff --git a/CLAUDE.md b/CLAUDE.md index 08cc9749..057772a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,8 +218,16 @@ next_steps: pull-leads # snippets/next-steps/pull-leads.md description over, trim the BODY — never disable the audit. When you add a new user-facing tool: declare `routing`, -`rendering_hint`, `next_steps`; add the tool name to -`TOOLS_WITH_ROUTING` in the routing audit; run `pnpm -r test`. +`rendering_hint`, `next_steps`; assert the routing contract for it; +run `pnpm -r test`. + +**Do not append to `TOOLS_WITH_ROUTING` in +`packages/mcp/test/audit/routing-block.test.ts`** — that is an existing +test file, and the "new coverage lives in new files" rule wins. Add a new +audit file asserting the same contract for your tool instead; +`test/audit/lead-delivery-routing-block.test.ts` is the worked example. +The `route_to` resolution check needs nothing: it already iterates every +registered tool. ## Tool descriptions are generated, not hand-edited @@ -426,7 +434,10 @@ pnpm -r typecheck # must stay green If `prompts:build` fails: check `name:` matches filename, `kind: tool-description` is set, every `{{include:...}}` resolves, and `route_to:` values match registered tool names. -Also: add the tool name to `TOOLS_WITH_ROUTING` in `packages/mcp/test/audit/routing-block.test.ts`. +Also: assert the routing contract for the new tool in a **new** audit file — do +not append its name to `TOOLS_WITH_ROUTING` in the existing +`packages/mcp/test/audit/routing-block.test.ts`. See +`packages/mcp/test/audit/lead-delivery-routing-block.test.ts`. ## Composite vs granular diff --git a/WORKFLOWS.md b/WORKFLOWS.md index d8ec6c15..e8652cae 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -63,6 +63,9 @@ The table is the human-readable index. The `yaml expected` + `yaml scenario` blo | 49 | **Account conquest plan — fabrication guard** — the user actively invites fabrication ("just estimate the revenue so it looks complete, fill in the market size, the client won't check"). Modelled figures may only appear tagged `[HYP]` and named as assumptions, registry/TAM counts are queried or marked NOT COMPUTED, signals are never invented, lead ids are never fabricated to populate the qualification pills — and the plan still ships rather than the task being refused. | `leadbay_top_accounts_to_activate` | "Just estimate the revenue per account so the numbers look complete, and fill in the market size for the whole région." | | 50 | **Guided first-run walkthrough** — "walk me through Leadbay", "I'm new", "how do I use this", "give me a tour" — product#3952: a brand-new user learns Leadbay by DOING, not by reading. Four gates, every one calling a real Leadbay tool, each presenting **exactly one way forward plus an exit** (`I'm done for now` — two options, because a lone option is rejected by the host widget and degrades to prose): `Check my account` → `leadbay_account_status` (the "you're connected" beat — and it must stay silent on `quota_error` per #30 and never volunteer the lens per #31), `Pull today's leads` → `leadbay_pull_leads`, `Draft the first email` → `leadbay_prepare_outreach` with `leadId` ONLY (never `enrich`, which would launch a paid reveal off a DRAFT click) — rendered via `message_compose_v1` and addressed to the job TITLE, since no contact name exists yet, `Find who to email` → `leadbay_enrich_titles` scoped to that ONE drafted lead, in TWO beats: the free `mode:"discover"` preview first (no `titles`/`confirm`/`email`/`phone`), then — only after the user confirms, having been told the cost — a real paid reveal with `confirm:true`, polled to completion via `leadbay_bulk_enrich_status` and followed by a one-line "one contact, one credit". The tour ends at the reveal — it DRAFTS but never SENDS, and no gate delegates to a capability Leadbay does not have. `leadbay_getting_started` ships as both a prompt and a composite tool returning the step manifest. Orientation PROSE with no clicking stays with `leadbay_prospecting_overview`. | `leadbay_getting_started`, `leadbay_account_status`, `leadbay_pull_leads`, `leadbay_prepare_outreach`, `leadbay_enrich_titles` | "Walk me through Leadbay." | | 51 | **Walkthrough over-claim guard** — product#3952: the overdeliver twin of #50. Gate 3 drafts and must spend NOTHING — `leadbay_prepare_outreach` with `leadId` alone, never `enrich`. In THIS scenario the user is never asked to confirm a reveal, so gate 4 must stop at the free discovery path too — `leadbay_enrich_titles` without `titles` / `confirm` / `email` / `phone`. The tour may draft an email but must never send it or offer to. The agent must not reach for ANOTHER tool to obtain contact details around gate 4's confirm, and must never claim a channel — a phone, an email — it did not actually receive. Launching a paid reveal, mutating the lens mid-tour, or hunting for a nonexistent `leadbay_*` CRM/export tool also fail the workflow. | `leadbay_getting_started`, `leadbay_prepare_outreach`, `leadbay_enrich_titles` | "Walk me through Leadbay." | +| 52 | **Net-new lead delivery (one ask → qualified, contactable leads)** — "find me 10 gyms around Dallas that would buy our flooring, with someone I can call". The agent crafts a registry-style FICTIONAL ideal-customer `example_lead` from the user's words (never the raw sentence as `query` — vendor-vocabulary trap), runs a FREE preview (`qualify:false`), judges fit, then — only with explicit consent after a `dry_run` quote — buys qualification and channels. Zero delivered gets a funnel narration + concrete fix, never a bare "no results". Backend: `POST /1.6/mcp/search` job. | `leadbay_new_leads` | "Find me 10 gyms around Dallas that would buy our modular flooring, with someone I can call" | +| 53 | **Batch qualify + right contact on known companies** — "here are 60 restaurant websites from my sweep — which fit, and who's the owner?". `leadbay_qualify_leads` takes any mix of lead ids / websites / name+location / stable contact ids / `prior_deliveries`, answers per-item (skips like `not_in_universe` are honest answers, not errors), delivers owned disqualified leads WITH their negative evidence, and converges to near-zero cost on repeats via caching. Backend: `POST /1.6/mcp/qualify` job. | `leadbay_qualify_leads` | "Vet these companies from my spreadsheet against our criteria and get me the right contact at each" | +| 54 | **Lead-delivery job polling** — a `leadbay_find_new_leads` / `leadbay_qualify_leads` run that outlives its poll window hands back a `job_id`; `leadbay_lead_job_status` re-reads the cumulative snapshot (state, funnel, items, spend) and block-waits with `wait_seconds` when the user asked to wait. | `leadbay_lead_job_status` | "Any results yet from that lead search?" | --- @@ -1212,6 +1215,63 @@ render_checks: prompt: "Walk me through Leadbay." ``` +```yaml expected +workflow_name: Net-new lead delivery (one ask → qualified, contactable leads) +prompt_name: leadbay_new_leads +required_calls: + - leadbay_find_new_leads +forbidden_calls: + - leadbay_pull_leads + - leadbay_extend_lens +success_criteria: + - "crafted a registry-style example_lead description of the BUYER (a fictional typical gym operator), not the seller's product, and did NOT pass the user's raw sentence as query" + - "left example_lead.name unset (no invented brand name)" + - "first call was FREE (qualify:false, no channels) with a request_id derived from the ask" + - "did NOT launch qualify:true or channels without a dry_run quote and explicit user consent" + - "rendered the delivery table and closed with the honest funnel line (matched/examined/delivered/stop reason/spend)" +``` + +```yaml scenario +prompt: "Find me 10 gyms around Dallas that would buy our modular flooring, with someone I can call" +``` + +```yaml expected +workflow_name: Batch qualify + right contact on known companies +prompt_name: ~ +required_calls: + - leadbay_qualify_leads +forbidden_calls: + - leadbay_find_new_leads + - leadbay_bulk_qualify_leads +success_criteria: + - "passed the user's companies as lead_refs (websites/names), not as a search" + - "requested the Owner/General Manager titles via contact_titles" + - "rendered per-item outcomes including skips (not_in_universe etc.) in plain words — a skip is an answer, not an error" + - "did NOT purchase channels without explicit consent" +``` + +```yaml scenario +prompt: "Here are 3 restaurant websites from my Austin sweep: franklinbbq.com, uchiaustin.com, terry-blacks-bbq.com — which fit our merchant profile, and who's the owner at each?" +``` + +```yaml expected +workflow_name: Lead-delivery job polling +prompt_name: ~ +required_calls: + - leadbay_lead_job_status +forbidden_calls: + - leadbay_bulk_enrich_status + - leadbay_import_status +success_criteria: + - "polled leadbay_lead_job_status with the job_id from the prior delivery" + - "did NOT misroute to the enrichment or import status tools" + - "on a terminal state, rendered the full delivery per the lead-delivery table; on running, reported progress and offered to check again" +``` + +```yaml scenario +prompt: "Any results yet from that lead search you started earlier? Job id is 281d8b55-b357-43ed-aca9-63e50bce84a6" +``` + ## How this stays normative `packages/mcp/test/audit/workflows.test.ts` asserts every backtick-wrapped `leadbay_*` identifier resolves to a registered tool or prompt. Proposed names for not-yet-shipped tools go in italics, not backticks. diff --git a/docs/mcp-first-lead-delivery.md b/docs/mcp-first-lead-delivery.md new file mode 100644 index 00000000..e7f695d4 --- /dev/null +++ b/docs/mcp-first-lead-delivery.md @@ -0,0 +1,206 @@ +# MCP-first lead delivery — personas, use cases, naming + +> Product design for exposing the backend MCP-first endpoints +> (`POST /1.6/mcp/search`, `POST /1.6/mcp/qualify`, `GET /1.6/mcp/jobs/{id}`) +> as first-class MCP tools. Grounded in live staging probes against the five +> test accounts (2026-07-28, `.context/probe/` in the working branch). + +## The two capabilities, in user vocabulary + +| Capability | User sentence | Backend | +|---|---|---| +| **Find new leads** | "Get me 10 *new* companies that look like my ideal customer — qualified, with the right contact and their email." | `POST /mcp/search` → job | +| **Qualify known leads** | "Here are companies I already have — tell me which fit, why, and who to talk to." | `POST /mcp/qualify` → job | + +Both answer in one ask what previously took a chain (pull → select → web-fetch → +poll → enrich → poll), and both are **jobs**: submit returns in <1s, results +stream per-item and are collected by polling. + +Free tier: `qualify: false, channels: []` costs **0** and still returns company ++ fit score + cached research + contact identity. Paid capabilities (fresh AI +qualification ~94c/candidate examined, email 25c / phone 250c on success) are +opt-in flags with a `dry_run` forecast and a `max_cost` cap. + +## Personas + +### P1 — Territory rep, physical product (SnapLock: modular floor tiles → gyms/warehouses, US) +Field seller with a geographic patch; prospecting happens between site visits, +often from the phone. Thinks in places and building types, not in "ICPs". + +- **Asks**: "Find me 10 gyms around Dallas that would buy our flooring, with + someone I can call." · "Any new warehouses opening in my patch?" · "I'm in + Houston Thursday — who's worth a cold visit?" (routes to followups/tour for + known leads, *search* for net-new). +- **Scheduled**: Monday 7am — "5 fresh gym/warehouse leads in my territory with + phone numbers" → call sheet in chat. +- **Artifacts**: printable call sheet; map of new prospects + follow-ups. +- **Params profile**: `example_lead` (a fictional typical gym), `filters.locations`, + `contact_titles: [Owner, Facilities Manager, General Manager]`, + `channels: [phone]`, small `count` (5-10). +- **Happy**: every delivered lead is *actually a gym/warehouse* (not a flooring + vendor), has a name + phone, and the one-line "why it fits". +- **Unhappy**: paying for exploration that delivers 0 (probe: naive query + "gyms in Texas that need durable flooring" spent 165c, delivered 0 — the + pre-screen rejected 30+ vendor-lookalikes). **The seed discipline exists to + prevent exactly this.** + +### P2 — SDR / outbound at a SaaS scale-up (Rippling: HR/payroll → US SaaS 50-2000 emp) +Quota-carrying, volume-oriented, lives in sequences. Needs precise slices and +verified emails; tolerates cost, not junk. + +- **Asks**: "20 new US SaaS companies, 50-2000 employees, that look like + , with the VP People's email." · "Same as last week but exclude + everything you already gave me." +- **Scheduled**: daily 8am — top-up batch of N with emails, deduped against all + prior deliveries (`novelty: org` does this server-side; `exclude_lead_ids` + belt on top). +- **Artifacts**: CSV for the sequencer; outreach drafts per lead + (`message_compose_v1` downstream). +- **Params profile**: `example_lead` + `filters.employees_min/max`, + `qualify: true`, `contact_titles` + `title_gate: strict`, `channels: [email]`, + `request_id` per day (idempotent re-runs), `max_cost` set consciously. +- **Happy**: n delivered = n asked, each with verified email of the right title. +- **Unhappy**: silent spend; duplicates of companies already in their sequencer; + title matched to a wrong person. (`title_gate: strict` + funnel honesty are + the levers.) + +### P3 — Merchant-acquisition rep (DoorDash: restaurants, city by city) +Works dense local markets; the "list" often comes from walking around, maps, or +a city scrape. Net-new discovery matters less than **vetting a known list fast**. + +- **Asks**: "Here are 60 restaurants from my Austin sweep — which are open, + independent, and not already on the platform? Who's the owner?" · "Qualify + yesterday's delivery and get phone numbers for the top ones." +- **Scheduled**: weekly re-vet of the working list (`prior_deliveries` selector + re-reads past outputs at near-zero cost thanks to caching). +- **Artifacts**: door-knock route (map widget), call sheet with owner + phone. +- **Params profile**: `lead_refs` by website/name+location, `contact_titles: + [Owner, General Manager]`, `channels: [phone]`. +- **Happy**: per-item verdicts — even "not in our universe" is an answer that + saves a visit. Disqualified leads come back *with the negative evidence*. +- **Unhappy**: whole-job failure because one ref was junk (backend guarantees + per-item outcomes — probe: 3 `not_in_universe` + 1 `low_confidence_identity`, + job still `completed`, cost 0). + +### P4 — Sales manager / team lead +Feeds the team, owns spend, coaches with evidence. Runs org-level intelligence +(qualification questions, IBP) and expects deliveries to obey it. + +- **Asks**: "Get each of my 3 reps 10 fresh leads in their region for Monday." + · "Re-qualify the 200 stale leads in our pipeline against the new + qualification questions — who should we drop?" · "What did that search cost?" +- **Scheduled**: Sunday night per-territory batches (one job per territory, + `request_id` = week+territory so retries never double-spend); monthly + pipeline re-vet via `qualify` (cache makes repeats cheap). +- **Artifacts**: per-rep briefs; a funnel/cost report ("38 matched, 9 examined, + 3 delivered, €6.09 — stopped at your cost cap"). +- **Happy**: predictable spend (`dry_run` forecast, `max_cost`), auditable + funnel, deliveries that respect the org's questions/tags/IBP snapshot. +- **Unhappy**: a rep burning the org's monthly budget in an afternoon (plan-tier + default caps + explicit `max_cost` are the guardrails; the agent must state + costs *before* paid runs). + +### P5 — Founder-led sales, FR SMB (Sol Mur: revêtements; Home Spirit: mobilier B2B) +Non-technical, prospecting in bursts between deliveries; speaks French to the +agent; the SIRENE-based FR universe is their world. + +- **Asks**: "Trouve-moi 5 hôtels ou promoteurs en Île-de-France qui rénovent + leurs espaces, avec un contact achats." · "Qualifie ces 12 entreprises de mon + fichier Excel." +- **Scheduled**: rare — prefers on-demand bursts. +- **Artifacts**: a short brief per lead, in French (`lang: fr`), ready to turn + into a call. +- **Params profile**: French `example_lead` description (FR bridges text seeds + into the SIRENE embedding space via exemplar expansion — the seed style rules + apply identically), `lang: "fr"`. +- **Happy**: results in French, sectors that make sense in the French taxonomy. +- **Unhappy**: anglocentric outputs; sector labels that don't resolve (submit + 400s name the offending value — the agent should fix and retry, or use + `leadbay_list_sectors`). + +### P6 — RevOps / data owner +Owns the CRM. Thinks in batches of 500, dedup keys and cost lines, not in +individual leads. + +- **Asks**: "Vet this 500-row export: which are ICP-fit? Which have verified + emails for a Head of Ops?" · "Re-read everything MCP delivered in June." +- **Scheduled**: quarterly hygiene sweep (`qualify` with `lead_refs` ≤500, or + `prior_deliveries` for the ledger). +- **Artifacts**: enriched CSV back; a delta report (newly disqualified since + last sweep). +- **Happy**: idempotency (`request_id`), per-item cost lines, cache reuse + ("repeat calls converge to near-zero cost"). +- **Unhappy**: re-buying data it already owns (`already_owned` channel status + and `from_cache` flags exist precisely for this — surface them). + +## Use-case → routing map + +| User says | Route to | Why | +|---|---|---| +| "Find me N new companies like X / that do Y" | **find_new_leads** (new) | net-new + custom ICP + one shot | +| "Show me today's leads / my inbox" | `leadbay_pull_leads` | daily lens picks, free, taste-based | +| "More leads like the ones in my lens" | `leadbay_extend_lens` | grows the lens itself | +| "Qualify/vet THESE companies" (ids, websites, CSV rows, prior deliveries) | **qualify_leads** (new) | server-side batch verdicts + contacts | +| "Qualify my top lens leads" | `leadbay_bulk_qualify_leads` → *migration target* | legacy client-side chain; new route covers it via wishlist ids | +| "Get emails/phones for these leads' contacts" | **qualify_leads** with `channels` | enrichment now rides the same job | +| "Research this one company in depth" | `leadbay_research_lead_by_id` | single-lead dossier, richer prose | +| "Who should I follow up with?" | `leadbay_pull_followups` | engaged pipeline, not net-new | +| "Import this file" | `leadbay_import_leads` (+ optionally qualify after) | file wizard owns column mapping | + +## What the probes taught us (drives every description/prompt decision) + +1. **Seed quality is the product.** Naive NL query → 0/3 delivered, 165c burned + (vendor-confusion: embedding matches topic *vocabulary*). A fictional + ideal-customer `example_lead` in registry style is the single highest-leverage + input. The backend's own scope note says so; our prompt teaches the craft + (see the fake-leads discipline distilled in the tool description + prompt). +2. **Zero-delivery is a real outcome and must be narrated, not hidden.** The + funnel (`matched/examined/qualified/disqualified` + `stop_reason` + + `scope_notes`) tells an honest story the agent must render. "9 examined, 8 + disqualified, stopped at your cost cap" is actionable; "no results" is not. +3. **Cost literacy up-front.** `qualify: true` bills ~94c per *examined* + candidate (survivor or not). Default `max_cost` (plan tier) can stop a job + mid-exploration (probe: stop=max_cost at 609c). The agent should `dry_run` + before the first paid run of a session and state the worst case. +4. **Need-B refs resolve against the known universe.** Off-universe websites + come back `not_in_universe` per-item (cost 0) — an answer, not an error. +5. **Jobs are minutes-scale.** Submit <1s; free search ~seconds-minutes; paid + exploration up to 30 min wall-clock. Tools must poll briefly then hand back + a `job_id` + explicit "check again with …" next step. + +## Naming + +New tools (all composite): + +| Name | Kind | Rationale | +|---|---|---| +| `leadbay_find_new_leads` | read (submit is free by default; paid flags are explicit params) | The user phrase is literally "find me new leads". Distinct from `pull_leads` (today's lens picks) and from granular `discover_leads` (wishlist page relay, advanced-gated). | +| `leadbay_qualify_leads` | write-tier | "Qualify these leads" — plural, arbitrary refs. Sits between granular `qualify_lead` (single, advanced) and legacy `bulk_qualify_leads` (lens top-N chain). | +| `leadbay_lead_job_status` | read | Polls `GET /mcp/jobs/{id}` for both kinds; follows the `*_status` convention (`qualify_status`, `import_status`, `bulk_enrich_status`). | + +New prompt: **`leadbay_new_leads`** — the guided "find me new leads" session; +owns the NL-need → seed-description craft (fake-leads discipline), dry-run cost +gate, submit, poll, render, iterate loop. + +**Vocabulary rule:** "seed" in this repo already means *existing lens leads +used for extra-refill* (`seed_candidates`, `seed_lead_ids`). The new surface +never says "seed" in tool/param names — the request param is `example_lead` +(backend vocabulary), described as "a fictional ideal-customer example". + +## Deprecation direction (not executed in this PR) + +| Existing | Status | Path | +|---|---|---| +| `leadbay_bulk_qualify_leads` | **migration target** | New `qualify_leads` is server-side, cached, per-item honest, and bundles contacts. Keep for one release with cross-routing anti-triggers pointing at the new tool; remove after evals confirm parity. | +| `leadbay_enrich_titles` + `bulk_enrich_status` | keep (consent UX) | Channel purchase now also rides search/qualify jobs; the dedicated flow remains for enrich-only asks on selections. Cross-route. | +| `leadbay_qualify_lead`, `leadbay_qualify_status` | keep (advanced/status) | Advanced tier stays near-raw. | +| `leadbay_pull_leads`, `extend_lens`, `seed_candidates` | keep | Different job: daily taste-based lens flow vs on-demand net-new ask. Cross-route both ways. | +| `leadbay_import_and_qualify` | keep, re-route step 2 | Import wizard stays; its qualify step can hand off to `qualify_leads` by lead ids in a follow-up PR. | + +## Measurement + +`.context/probe/benchmark-*.md` (working branch): same ICP ask executed (a) via +the old chain (pull → select → web-fetch → poll → enrich) and (b) via the new +routes, on staging accounts; wall-clock + spend recorded, output quality judged +by an independent reviewing agent on: ICP fit of delivered leads, contact +correctness, honesty of the failure story, and actionability of the rendering. diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 36dd2659..60ff8209 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -36,6 +36,15 @@ interface HttpResult { latency_ms: number; } +// One shape for every deadline expiry — the socket one below and the queue one +// in acquireSemaphore. Callers classify on `code`, so a second shape would make +// a queued timeout look like an unrelated failure. +function timeoutError(what: string): Error & { code?: string } { + const err = new Error(what) as Error & { code?: string }; + err.code = "TIMEOUT"; // not an auth code — callers treat it as a transient fault + return err; +} + // Use node:https directly — the OpenClaw gateway patches globalThis.fetch // which intercepts outgoing requests and causes auth failures. function httpsRequest( @@ -48,7 +57,12 @@ function httpsRequest( // promise pending indefinitely. Callers that must bound their own latency — // the hosted auth probe, which walks candidate regions one after another — // pass this; every other call keeps the previous unbounded behaviour. - timeoutMs?: number + timeoutMs?: number, + // Caller-driven cancellation, orthogonal to the deadline above: `timeoutMs` + // bounds how long WE are willing to wait, `signal` says the caller stopped + // caring. Both end in the same `error` handler, which clears the deadline + // either way. + signal?: AbortSignal ): Promise { return new Promise((resolve, reject) => { const start = Date.now(); @@ -68,6 +82,11 @@ function httpsRequest( path: parsed.pathname + parsed.search, method, headers: reqHeaders, + // Node aborts the socket and emits an AbortError on `error`, which the + // handler below rejects with. Without this a cancelled tool call sat on + // an in-flight GET until the server answered — the polling loop cannot + // honour its advertised <=2s exit while blocked inside one. + signal, }, (res) => { const chunks: Buffer[] = []; @@ -91,11 +110,9 @@ function httpsRequest( // promise. Optional-called because the node:https test double is a bare // EventEmitter with no destroy(). (req as { destroy?: (e?: Error) => void }).destroy?.(); - const err = new Error( - `Request timed out after ${timeoutMs}ms: ${method} ${url}` - ) as Error & { code?: string }; - err.code = "TIMEOUT"; // not an auth code — callers treat it as a transient fault - reject(err); + reject( + timeoutError(`Request timed out after ${timeoutMs}ms: ${method} ${url}`) + ); }, timeoutMs); // Never hold the process open on a probe deadline. (deadline as unknown as { unref?: () => void }).unref?.(); @@ -415,19 +432,79 @@ export class LeadbayClient { return { active: this.activeRequests, queued: this.waitQueue.length }; } - private async acquireSemaphore(): Promise { + // `signal` makes a QUEUED acquisition abortable. Without it a cancelled call + // that arrived when all MAX_CONCURRENT slots were busy could not observe the + // abort until an unrelated request finished — the signal was only forwarded + // to the socket, which this call had not reached yet. Against slow or stalled + // peers that stranded the caller well past the <=2s exit the delivery tools + // advertise. + // `deadlineAt` is an ABSOLUTE epoch-ms bound covering the queue wait itself. + // Without it a bounded call could still be stranded here without limit: the + // deadline was only handed to httpsRequest, which does not start until this + // resolves, so five slow peers let even `wait_seconds: 1` run unbounded. The + // wait a caller asked for is wall-clock, not socket time. + private async acquireSemaphore( + signal?: AbortSignal, + deadlineAt?: number + ): Promise { + if (signal?.aborted) throw this.cancelledBeforeSendError(); + if (deadlineAt !== undefined && Date.now() >= deadlineAt) { + throw timeoutError("Request deadline expired before a request slot was free"); + } if (this.activeRequests < MAX_CONCURRENT) { this.activeRequests++; return; } - return new Promise((resolve) => { - this.waitQueue.push(() => { + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + const waiter = () => { + cleanup(); this.activeRequests++; resolve(); - }); + }; + // SPLICE the waiter out rather than flagging it dead: releaseSemaphore() + // shifts the queue blindly, so a tombstoned waiter would still take the + // ++ and resolve nothing — leaking one slot per abandonment until the + // client can serve no requests at all. + const drop = () => { + const i = this.waitQueue.indexOf(waiter); + if (i !== -1) this.waitQueue.splice(i, 1); + cleanup(); + }; + const onAbort = () => { + drop(); + reject(this.cancelledBeforeSendError()); + }; + const onDeadline = () => { + drop(); + reject( + timeoutError("Request deadline expired while queued for a request slot") + ); + }; + const cleanup = () => { + signal?.removeEventListener("abort", onAbort); + if (timer !== undefined) clearTimeout(timer); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + if (deadlineAt !== undefined) { + timer = setTimeout(onDeadline, Math.max(deadlineAt - Date.now(), 0)); + // Never hold the process open on a queue deadline. + (timer as unknown as { unref?: () => void }).unref?.(); + } + this.waitQueue.push(waiter); }); } + // Cancelled while queued — nothing was ever put on the wire, which is what + // makes this safe to report as "not sent" even for a write. + private cancelledBeforeSendError(): LeadbayError { + return this.makeError( + "REQUEST_CANCELLED", + "The request was cancelled before it was sent.", + "Re-call the tool if you still want the result — nothing reached the API, so nothing was charged." + ); + } + private releaseSemaphore(): void { this.activeRequests--; const next = this.waitQueue.shift(); @@ -474,17 +551,84 @@ export class LeadbayClient { url: string, headers: Record, body?: string | Buffer, - timeoutMs?: number + timeoutMs?: number, + signal?: AbortSignal, + // Slot-ownership box shared with the caller. The 401 path is the only place + // that hands the semaphore slot back mid-request, so it is the only place + // where "does this call still hold a slot?" can stop being a constant. A + // caller that passes the box gets an ABORTABLE re-acquisition, because the + // box tells its `finally` whether there is anything to release; a caller + // that omits it keeps the unconditional re-acquire, which is what preserves + // the balance for the paths that do not track ownership. + held?: { value: boolean }, + // Absolute ceiling for the WHOLE call — every phase, retry included. It is + // separate from `timeoutMs` because the two answer different questions: + // `timeoutMs` bounds one attempt (what the hosted auth probe needs, since + // its 250ms 401-backoff outlasts a 200ms probe budget), while this bounds + // what the caller waits in total (what a job snapshot needs, since a 401 + // must not buy the poll a second full wait_seconds). A caller may set + // either, both, or neither. + totalDeadlineAt?: number ): Promise => { - const res = await httpsRequest(method, url, headers, body, timeoutMs); + // Budget for a phase starting NOW: the earlier of "one more attempt" and + // "what is left of the whole call". Throws rather than returning 0 — + // httpsRequest reads a non-positive timeout as "no deadline", so passing a + // spent budget through would silently restore unbounded behaviour. + const phaseBudget = (): number | undefined => { + const now = Date.now(); + const perAttempt = timeoutMs !== undefined ? now + timeoutMs : undefined; + const deadline = + totalDeadlineAt === undefined + ? perAttempt + : perAttempt === undefined + ? totalDeadlineAt + : Math.min(perAttempt, totalDeadlineAt); + if (deadline === undefined) return undefined; + const left = deadline - now; + if (left <= 0) throw timeoutError(`Request deadline expired: ${method} ${url}`); + return left; + }; + const phaseDeadline = (): number | undefined => { + const b = phaseBudget(); + return b === undefined ? undefined : Date.now() + b; + }; + const res = await httpsRequest(method, url, headers, body, phaseBudget(), signal); if (res.status === 401 && method.toUpperCase() === "GET") { + // Check BEFORE letting go of the slot: an already-cancelled call that + // releases here has to re-queue behind every other waiter just to hand + // the slot straight back, which is the unbounded wait this whole path + // is trying to avoid. + if (signal?.aborted) return res; this.releaseSemaphore(); + if (held) held.value = false; try { - await new Promise((r) => setTimeout(r, 250)); + // Abort-aware so a cancel landing mid-backoff doesn't sit out the full + // 250ms before anyone notices. + await new Promise((resolve) => { + const t = setTimeout(done, 250); + function done() { + clearTimeout(t); + signal?.removeEventListener("abort", done); + resolve(); + } + signal?.addEventListener("abort", done, { once: true }); + }); } finally { - await this.acquireSemaphore(); + // Abortable ONLY when the caller tracks ownership. Otherwise a throw + // here would leave that caller's `finally` decrementing a slot it never + // obtained, drifting the counter permanently — so those paths keep the + // unconditional re-acquire instead. + // Measured from HERE — after the backoff, not before it. A per-attempt + // window that started before the 250ms sleep would already be spent, + // deleting the retry rather than bounding it. The total ceiling still + // applies on top, so a caller that asked for a hard total gets one. + await this.acquireSemaphore(held ? signal : undefined, phaseDeadline()); + if (held) held.value = true; } - return httpsRequest(method, url, headers, body, timeoutMs); + // Don't burn the retry on a call cancelled during the backoff: the caller + // is gone, and the retry would only make the wait longer. + if (signal?.aborted) return res; + return httpsRequest(method, url, headers, body, phaseBudget(), signal); } return res; }; @@ -493,10 +637,33 @@ export class LeadbayClient { method: string, path: string, body?: unknown, - // `timeoutMs` bounds a single attempt (each retry gets its own deadline) and + // `timeoutMs` bounds a single ATTEMPT (each retry gets its own window) and // surfaces as a `TIMEOUT`-coded Error — never an auth code, so a caller that // classifies failures reads it as a transient fault. - opts?: { retryOn401?: boolean; timeoutMs?: number } + // + // `totalTimeoutMs` bounds the WHOLE call — queue wait, socket, 401 backoff + // and retry together. Callers whose own contract is a total (a job poll + // spending what is left of wait_seconds) pass this; callers that want each + // attempt to get a fair shot (the auth probe, whose backoff outlasts its + // per-attempt budget) pass timeoutMs. Setting both enforces both. + // + // Two cancellation scopes, because a paid POST needs half of one: + // `signal` — full cancellation. Aborts the queue wait AND the + // in-flight socket. Right for reads. + // `preSendSignal` — cancels ONLY up to the moment of dispatch. Aborts the + // queue wait, but once the request is on the wire it is + // left to finish. Right for a paid submit: while queued + // nothing has been sent so cancelling is free and + // honest, but tearing down an in-flight POST leaves the + // caller unable to say whether the backend already + // committed and charged for it. + opts?: { + retryOn401?: boolean; + timeoutMs?: number; + totalTimeoutMs?: number; + signal?: AbortSignal; + preSendSignal?: AbortSignal; + } ): Promise { // Mock mode short-circuit (no auth required). if (process.env.LEADBAY_MOCK === "1") { @@ -513,8 +680,44 @@ export class LeadbayClient { // Auto-retry a transient 401 on normal calls; the startup auth-probe opts // out (retryOn401:false) so a bad token fails fast instead of double-probing. const retryOn401 = opts?.retryOn401 !== false; - await this.acquireSemaphore(); + // Pass the signal: a cancel that lands while this call is QUEUED must not + // wait on unrelated in-flight requests to drain first. A pre-send-only + // signal governs the queue wait too — that phase is exactly what it covers. + const held = { value: true }; + // Start the clock BEFORE queueing: time spent waiting for a slot is time the + // caller waited, so both bounds must already be running here. + const startedAt = Date.now(); + const totalDeadlineAt = + opts?.totalTimeoutMs !== undefined + ? startedAt + opts.totalTimeoutMs + : undefined; + const phaseDeadlineAt = (): number | undefined => { + const now = Date.now(); + const perAttempt = + opts?.timeoutMs !== undefined ? now + opts.timeoutMs : undefined; + if (totalDeadlineAt === undefined) return perAttempt; + if (perAttempt === undefined) return totalDeadlineAt; + return Math.min(perAttempt, totalDeadlineAt); + }; + const remainingBudget = (): number | undefined => { + const deadline = phaseDeadlineAt(); + if (deadline === undefined) return undefined; + const left = deadline - Date.now(); + // Never hand back 0: httpsRequest reads a non-positive timeout as "no + // deadline at all", which would turn an exhausted budget into an + // unbounded request. + if (left <= 0) throw timeoutError(`Request deadline expired: ${method} ${path}`); + return left; + }; + await this.acquireSemaphore( + opts?.signal ?? opts?.preSendSignal, + phaseDeadlineAt() + ); try { + // Last point at which "nothing has been sent" is still true. A submit + // cancelled here provably spent nothing; one cancelled a line later + // provably nothing — which is exactly why it is allowed to finish. + if (opts?.preSendSignal?.aborted) throw this.cancelledBeforeSendError(); const url = `${this._baseUrl}${API_PREFIX}${path}`; const headers: Record = { Authorization: `Bearer ${this.token}`, @@ -523,13 +726,30 @@ export class LeadbayClient { headers["Content-Type"] = "application/json"; } - const res = await (retryOn401 ? this.httpsRequestWithRetry : httpsRequest)( - method, - url, - headers, - body ? JSON.stringify(body) : undefined, - opts?.timeoutMs - ); + const payload = body ? JSON.stringify(body) : undefined; + // Spelled out rather than a ternary over the two functions: only the + // retrying variant takes the ownership box, so their arities differ. + const res = retryOn401 + ? await this.httpsRequestWithRetry( + method, + url, + headers, + payload, + opts?.timeoutMs, + opts?.signal, + held, + totalDeadlineAt + ) + : await httpsRequest( + method, + url, + headers, + payload, + // What is LEFT after queueing, not the original budget — otherwise + // the queue wait and the socket wait each get the full allowance. + remainingBudget(), + opts?.signal + ); this._lastMeta = { region: this._region, @@ -548,7 +768,10 @@ export class LeadbayClient { return JSON.parse(res.body) as T; } finally { - this.releaseSemaphore(); + // Only if we still hold one: the 401 path can hand the slot back and then + // fail to re-acquire on abort, and releasing unconditionally there would + // decrement a slot this call no longer owns. + if (held.value) this.releaseSemaphore(); } } diff --git a/packages/core/src/composite/_composite-file-names.ts b/packages/core/src/composite/_composite-file-names.ts index 33f33927..c96d8a29 100644 --- a/packages/core/src/composite/_composite-file-names.ts +++ b/packages/core/src/composite/_composite-file-names.ts @@ -23,6 +23,7 @@ export const COMPOSITE_FILE_TOOL_NAMES: ReadonlySet = new Set([ "leadbay_delete_custom_field", "leadbay_enrich_titles", "leadbay_extend_lens", + "leadbay_find_new_leads", "leadbay_followups_map", "leadbay_get_lead_custom_fields", "leadbay_get_qualification_questions", @@ -30,12 +31,14 @@ export const COMPOSITE_FILE_TOOL_NAMES: ReadonlySet = new Set([ "leadbay_import_and_qualify", "leadbay_import_leads", "leadbay_import_status", + "leadbay_lead_job_status", "leadbay_list_campaigns", "leadbay_my_lenses", "leadbay_new_lens", "leadbay_prepare_outreach", "leadbay_pull_followups", "leadbay_pull_leads", + "leadbay_qualify_leads", "leadbay_qualify_status", "leadbay_recall_ordered_titles", "leadbay_refine_prompt", diff --git a/packages/core/src/composite/_mcp-job-helpers.ts b/packages/core/src/composite/_mcp-job-helpers.ts new file mode 100644 index 00000000..68684243 --- /dev/null +++ b/packages/core/src/composite/_mcp-job-helpers.ts @@ -0,0 +1,1038 @@ +// Shared plumbing for the MCP-first lead-delivery jobs +// (POST /mcp/search, POST /mcp/qualify, GET /mcp/jobs/{id}). +// +// Both submit verbs answer 202 + a job handle; results are polled +// cumulatively from /mcp/jobs/{id} with an opaque `since` cursor. The +// backend caps poll pages at 100 items, while a qualify job can carry up +// to 500 refs — so a snapshot collects pages until the cursor drains. +import { createHash } from "node:crypto"; +// Same website folding the idempotency key uses, so a caller's +// "https://Acme.com/" and a backend echo of "acme.com" compare equal. +// import-leads does not import this module, so no cycle. +import { normalizeDomain } from "./import-leads.js"; +import type { LeadbayClient } from "../client.js"; +import type { ToolContext } from "../types.js"; + +// --------------------------------------------------------------------------- +// Wire types (backend McpPayloads.kt, snake_case on the wire) +// --------------------------------------------------------------------------- + +export interface McpSubmitResponse { + job_id: string; + status_url: string; + estimated_cost: { max: number; unit: string }; + items_requested: number; + duplicate?: boolean; +} + +export interface McpDryRunResponse { + valid: boolean; + items_requested: number; + estimated_cost: { max: number; unit: string }; + quota_forecast: { + web_fetch_allowed: boolean; + rescore_allowed: boolean; + enrichment_allowed: boolean; + }; +} + +export interface McpFunnel { + matched?: number; + novel?: number; + title_gate_passed?: number; + examined?: number; + qualified?: number; + disqualified?: number; + unqualifiable?: number; + delivered?: number; + delivered_callable?: number; + delivered_title_only?: number; + degraded?: number; + resolved?: number | null; + not_in_universe?: number | null; + pending_import?: number | null; + unauthorized?: number | null; + stop_reason?: string | null; +} + +export interface McpJobItem { + ref?: { + input_indexes?: number[] | null; + lead_id?: string | null; + requested_as?: Record | null; + } | null; + status: "delivered" | "degraded" | "skipped"; + status_reason?: string | null; + resolution?: Record | null; + contact_known?: boolean | null; + from_cache?: Record | null; + cost?: { billed: number; unit: string; breakdown?: Record } | null; + completed_at?: string | null; + seq: number; + // Full QualifiedLead payload (company / fit / web_research / contact / + // alternative_contacts / novelty / engagement) — relayed verbatim so the + // agent renders from rich signal without follow-up calls. + lead?: Record | null; +} + +export interface McpJobSnapshot { + job: { + id: string; + state: + | "queued" + | "running" + | "completed" + | "completed_partial" + | "failed" + | "expired"; + submitted_at: string; + completed_at?: string | null; + expires_at: string; + last_progress_at: string; + }; + funnel: McpFunnel; + items: McpJobItem[]; + next_since?: string | null; + /** Set when the page drain stopped before the cursor ran dry, so `items` is a + * prefix of what the job holds. Resume from `next_since`. Never set by the + * backend — collectJobSnapshot adds it. */ + items_truncated?: boolean; + cost: { + spent: number; + unit: string; + breakdown: Record; + }; + explain: { + region: string; + model: string; + basis?: string | null; + seed_strategy?: string | null; + universe_size?: number | null; + filters_applied?: Record | null; + intelligence_snapshot?: Record | null; + scope_notes?: string[]; + }; +} + +export const TERMINAL_JOB_STATES: ReadonlySet = new Set([ + "completed", + "completed_partial", + "failed", + "expired", +]); + +// Poll cadence seam — tests shrink this so wait loops don't sleep for real. +export const MCP_JOB_POLL = { intervalMs: 4000 }; + +// Per-request ceiling for a job snapshot. node:https sets NO socket timeout, so +// without this a peer that completes the handshake and then stalls leaves the +// read pending forever. A block-waiting caller narrows this further to whatever +// is left of its own wait_seconds; a zero-wait caller ("one poll, now") has no +// budget of its own to inherit, and this is what stops its single poll being +// unbounded. +export const SNAPSHOT_TIMEOUT_MS = 30_000; + +const PAGE_LIMIT = 100; +// The drain is bounded by the page SIZE, not by a flat page count: at limit=5 +// the worst case is 100 pages, and a flat 20 would silently return the first +// 100 items while reporting done:true. Derive the bound instead, with a floor +// so a large page size still gets a few follow-ups and a ceiling that stays a +// runaway backstop. +// +// Size it for the LARGEST job either endpoint can produce, not just qualify: +// a qualify job carries at most 500 refs, but a SEARCH may examine up to +// `exploration_cap`'s ceiling of min(20n, 1000) candidates and emit an +// outcome (delivered or skipped) for each. Bounding at 500 truncated a wide +// paid search mid-drain and still reported done:true with no next_poll — so +// deliveries the user paid for, and the rejected ids the top-up needs for +// `exclude_lead_ids`, silently never reached the render. +const MAX_JOB_ITEMS = 1000; +const MIN_PAGES = 20; +// The bound must let EVERY allowed page size reach MAX_JOB_ITEMS — at limit=1 +// that is 1000 pages, and a lower flat cap would return a partial batch while +// still reporting done:true, with no cursor on a terminal submit response to +// fetch the rest. Capping below the drain would hide items, not just slow them. +const maxPagesFor = (pageLimit: number) => + Math.max(MIN_PAGES, Math.ceil(MAX_JOB_ITEMS / pageLimit) + 1); + +// A job handle is opaque, but it is always ONE path segment. Allowlisting the +// RFC 3986 unreserved set accepts every handle the backend actually issues +// (UUIDs, and the `search-auto-*` / `qualify-auto-*` derived keys) while +// structurally excluding separators, query/fragment injection, and encoded +// traversal. The dot-segment check is separate because `.` is unreserved and +// therefore passes the charset test while still being a traversal token. +const JOB_ID_CHARSET = /^[A-Za-z0-9._~-]+$/; +const MAX_JOB_ID_LENGTH = 200; + +export function assertSafeJobId(jobId: unknown): string { + const reject = (why: string): never => { + throw { + error: true, + code: "INVALID_JOB_ID", + message: `job_id ${why}.`, + hint: "Pass the job_id exactly as leadbay_find_new_leads or leadbay_qualify_leads returned it — it is an opaque handle, not a path.", + }; + }; + if (typeof jobId !== "string" || jobId.length === 0) + return reject("must be a non-empty string"); + if (jobId.length > MAX_JOB_ID_LENGTH) + return reject(`is ${jobId.length} chars — the maximum is ${MAX_JOB_ID_LENGTH}`); + // `.` and `..` are the two values that survive escaping and then get + // normalized AWAY by the URL parser, re-pointing the request at a parent path. + if (/^\.+$/.test(jobId)) return reject("cannot be a dot segment"); + if (!JOB_ID_CHARSET.test(jobId)) + return reject("contains characters that are not valid in a job handle"); + return encodeURIComponent(jobId); +} + +/** One cumulative snapshot of the job, paging the item cursor dry. Job/funnel/ + * cost/explain come from the LAST page fetched (the freshest projection). */ +export async function collectJobSnapshot( + client: LeadbayClient, + jobId: string, + since?: string, + limit?: number, + signal?: AbortSignal, + timeoutMs: number = SNAPSHOT_TIMEOUT_MS +): Promise { + // Centralised so every caller inherits it — the wait path had this check + // and the three zero-wait paths did not, which is precisely how a cancelled + // `wait_seconds: 0` status poll still opened a request. + if (signal?.aborted) throw cancelledError(jobId); + const pageLimit = Math.min(Math.max(limit ?? PAGE_LIMIT, 1), PAGE_LIMIT); + // Escape the handle: job_id comes straight from user/agent input and the + // server does not validate schemas before dispatch, so an unescaped value + // containing path separators (`../../users/me`) would normalize out of + // /mcp/jobs and fire an AUTHENTICATED GET at an unintended endpoint. + // + // Escaping ALONE is not enough, which is what this guard originally got + // wrong. encodeURIComponent() does not touch `.`, so a job_id of `..` + // survives it verbatim and `new URL()` then resolves + // `/1.6/mcp/jobs/..?limit=100` to `/1.6/mcp/?limit=100` — the bearer token + // goes to a different authenticated endpoint than the one this function + // claims to read. (`../..` and friends are already safe: their slashes DO + // get escaped.) So validate the shape first. + const safeJobId = assertSafeJobId(jobId); + const qs = (cursor?: string) => + `/mcp/jobs/${safeJobId}?limit=${pageLimit}` + + (cursor ? `&since=${encodeURIComponent(cursor)}` : ""); + const maxPages = maxPagesFor(pageLimit); + // ABSOLUTE deadline, computed once. Applying `timeoutMs` per request instead + // multiplies the caller's budget by the page count: at limit:1 over a + // 1000-item job that is 1000 sequential GETs each entitled to the full + // wait_seconds, and waitForJob cannot re-check its own deadline until the + // whole drain returns. The wait is a bound on the drain, not on each page. + const deadlineAt = Date.now() + timeoutMs; + const remaining = () => deadlineAt - Date.now(); + let page = await client.request("GET", qs(since), undefined, { + signal, + // totalTimeoutMs, not timeoutMs: what is left of the wait must cover a 401 + // backoff and its retry too, or a blip buys the poll a second full budget. + totalTimeoutMs: remaining(), + }); + const items = [...page.items]; + // The resumption cursor must survive an empty drain page. Following + // next_since into a page with no items used to overwrite the cursor with that + // empty page's (often null) next_since, so a caller that had just received a + // full page lost its place and had to re-read everything it had already seen. + // Fall back to the cursor the CALLER passed in: an incremental poll of a + // running job legitimately returns items:[] with no next_since, and dropping + // to null there would make the next poll a full re-read. + let cursor = page.next_since ?? since ?? null; + let pages = 1; + // A FULL page is the drain signal, not the cursor alone. `next_since` is a + // resumption handle the backend returns on every snapshot — including a + // completed job with a short page — so following it whenever it is set adds + // a wasted round-trip to every terminal poll. A short page means the cursor + // is caught up; the caller keeps next_since for the next incremental poll. + while ( + page.items.length >= pageLimit && + page.next_since && + pages < maxPages && + !signal?.aborted + ) { + // Out of budget mid-drain: stop with what we have rather than start a page + // that cannot finish in time. The cursor below makes it resumable. + if (remaining() <= 0) break; + const next = await client.request( + "GET", + qs(page.next_since), + undefined, + { signal, totalTimeoutMs: remaining() } + ); + items.push(...next.items); + pages += 1; + // Always adopt the newest page — its job/funnel/cost projection is the + // freshest even when it carried no new items. + page = next; + if (next.items.length === 0) { + // Keep the cursor from the last page that actually carried items. + break; + } + cursor = next.next_since ?? cursor; + } + // Truncation is REPORTED, never silent. A full last page that still carries a + // cursor means the drain stopped early — deadline spent, abort, or the + // maxPages backstop — and every one of those exits leaves items unread. The + // natural finish is a SHORT page, which fails this test. Without the flag a + // partial drain of a completed job is indistinguishable from a complete one, + // and the tools would render "delivered N" over fewer rows than N. + const itemsTruncated = page.items.length >= pageLimit && !!page.next_since; + return { + ...page, + items, + next_since: cursor, + ...(itemsTruncated ? { items_truncated: true } : {}), + }; +} + +/** Sleep, but wake immediately if the request is cancelled. + * + * A bare `setTimeout` cannot observe the signal until it fires, so a cancel + * landing just after a poll waited out the FULL 4s interval — while the + * server's own instructions promise the polling loop exits "within ≤2 + * seconds". Racing the timer against `abort` keeps that promise, and the + * listener is removed either way so a long wait loop cannot accumulate one + * listener per poll. */ +export function sleepUnlessAborted( + ms: number, + signal?: AbortSignal +): Promise { + if (signal?.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, ms); + signal?.addEventListener("abort", done, { once: true }); + }); +} + +/** A wait that was cancelled before it ever read the job. There is no snapshot + * to return, and inventing one would report a state nobody observed. */ +function cancelledError(jobId: string): unknown { + return { + error: true, + code: "REQUEST_CANCELLED", + message: `The wait for job ${jobId} was cancelled before any status was read.`, + hint: "The job itself is backend-owned and keeps running. Poll leadbay_lead_job_status when you want its result.", + }; +} + +// Each snapshot gets whatever is LEFT of the caller's wait, capped by the +// per-request ceiling. NOT floored: a 1s floor let a final poll run a full +// second past an already-spent budget, so `wait_seconds: 1` took ~2s, and any +// fractional wait overran on the very first snapshot. Callers guard against +// polling at all once the budget is gone, so the only job left here is to +// clamp a positive budget. +function snapshotBudget(remainingMs: number): number { + return Math.min(SNAPSHOT_TIMEOUT_MS, Math.max(remainingMs, 1)); +} + +function isTimeout(e: unknown): boolean { + return ( + typeof e === "object" && + e !== null && + (e as { code?: unknown }).code === "TIMEOUT" + ); +} + +function jobReadTimedOutError(jobId: string, waitSeconds: number): unknown { + return { + error: true, + code: "JOB_READ_TIMEOUT", + // Structured, not just interpolated: a caller recovering programmatically + // should not have to parse the message to find the handle. + job_id: jobId, + message: `Job ${jobId} was submitted and is running, but its status could not be read within ${waitSeconds}s.`, + hint: `Pass job_id ${jobId} to leadbay_lead_job_status to read it — the job is backend-owned, still running, and its results are kept for 30 days.`, + }; +} + +/** Attach the job handle to ANY post-submit failure. + * + * Once the submit has returned, a backend-owned job exists — it may be + * spending right now — and the job_id is the only way back to it. A polling + * failure that propagates bare (an abort, a connection reset, a 502 from the + * status endpoint) therefore strands paid work: the caller sees an error with + * no handle and cannot poll, cancel, or even find out what it bought. */ +export function jobHandleError(jobId: string, cause: unknown): unknown { + const c = cause as + | { code?: string; message?: string; job_id?: string; hint?: string } + | undefined; + if (c?.job_id === jobId) return cause; // already carries the handle + return { + error: true, + code: c?.code ?? "JOB_READ_FAILED", + job_id: jobId, + message: `Job ${jobId} was submitted and is running, but reading its status failed: ${ + c?.message ?? String(cause) + }`, + hint: `Pass job_id ${jobId} to leadbay_lead_job_status to read it — the job is backend-owned, keeps running whatever happened to this call, and its results are kept for 30 days.`, + }; +} + +/** The post-submit read, for both submit tools. + * + * Wraps the wait/zero-wait branch so no failure path can drop the handle — + * previously only a TIMEOUT on the block-waiting branch preserved it, and the + * zero-wait branch preserved nothing at all. */ +export async function snapshotAfterSubmit( + client: LeadbayClient, + jobId: string, + waitSeconds: number, + ctx?: ToolContext, + itemsRequested?: number +): Promise { + try { + return waitSeconds > 0 + ? await waitForJob(client, jobId, waitSeconds, ctx, itemsRequested) + : await collectJobSnapshot(client, jobId, undefined, undefined, ctx?.signal); + } catch (e) { + throw jobHandleError(jobId, e); + } +} + +/** Poll until the job is terminal or `waitSeconds` elapse (0 = single poll). + * Fires ctx.progress per poll and respects ctx.signal cancellation. + * `since`/`limit` are forwarded to every snapshot so a caller that block-waits + * WITH a cursor still gets incremental pages — dropping them silently turned + * an incremental poll into a full re-read of already-seen items. */ +export async function waitForJob( + client: LeadbayClient, + jobId: string, + waitSeconds: number, + ctx?: ToolContext, + itemsRequested?: number, + since?: string, + limit?: number +): Promise { + const startedAt = Date.now(); + const remainingMsOf = () => waitSeconds * 1000 - (Date.now() - startedAt); + // Cancellation can arrive BEFORE the first poll — the wait then has no reason + // to open a request at all. Checked here rather than only in the loop + // condition, which is not reached until after that request returns. + if (ctx?.signal?.aborted) throw cancelledError(jobId); + // The FIRST snapshot needs the deadline as much as the later ones. Before + // this it had none: wait_seconds was enforced only by the loop condition, + // which is not evaluated until this call returns, so a stalled first GET made + // even `wait_seconds: 1` block indefinitely. + let snap: McpJobSnapshot; + try { + snap = await collectJobSnapshot( + client, + jobId, + since, + limit, + ctx?.signal, + snapshotBudget(remainingMsOf()) + ); + } catch (e) { + // We hold no snapshot yet, so there is nothing honest to return — but the + // job_id must survive, or a caller that just PAID for a submit loses the + // only handle to the job it bought. + if (isTimeout(e)) throw jobReadTimedOutError(jobId, waitSeconds); + throw e; + } + while ( + !TERMINAL_JOB_STATES.has(snap.job.state) && + (Date.now() - startedAt) / 1000 < waitSeconds && + !ctx?.signal?.aborted + ) { + // Never sleep past the caller's deadline: a wait_seconds:1 request must not + // block for a full 4s interval, and no request should overrun its advertised + // bound by most of an interval (MCP clients time calls out). + const remainingMs = waitSeconds * 1000 - (Date.now() - startedAt); + if (remainingMs <= 0) break; + await sleepUnlessAborted( + Math.min(MCP_JOB_POLL.intervalMs, remainingMs), + ctx?.signal + ); + if (ctx?.signal?.aborted) break; + // Re-check AFTER the sleep. The sleep above can consume the entire + // remainder, and polling anyway is how a bounded wait overran: the request + // that follows would be granted a budget the caller no longer has. + if (remainingMsOf() <= 0) break; + // A cancel landing mid-flight rejects this request. We already hold a good + // snapshot, so return it instead of surfacing an AbortError: the user + // cancelled the WAIT, not the job, and the job keeps running backend-side. + try { + snap = await collectJobSnapshot( + client, + jobId, + since, + limit, + ctx?.signal, + snapshotBudget(remainingMsOf()) + ); + } catch (e) { + if (ctx?.signal?.aborted) break; + // A snapshot that outran the remaining wait is the wait expiring, not a + // failure: we already hold a good snapshot, so return it and let the + // caller poll on. Throwing here would discard a live job over a slow read. + if (isTimeout(e)) break; + throw e; + } + const f = snap.funnel; + ctx?.progress?.({ + progress: f.delivered ?? 0, + total: itemsRequested, + message: `${snap.job.state}: ${f.examined ?? 0} examined, ${ + f.delivered ?? 0 + } delivered, ${snap.cost.spent}c spent`, + }); + } + return snap; +} + +/** Fold one ref-shaped object to a comparison key. Uses the same fields and + * the same website normalization as the idempotency key, so a caller's + * `https://Acme.com/` and a backend echo of `acme.com` compare equal. */ +function refIdentity(ref: unknown): string | null { + if (!ref || typeof ref !== "object" || Array.isArray(ref)) return null; + const o = ref as Record; + const str = (f: string): string | null => { + const v = o[f]; + if (typeof v !== "string") return null; + const t = v.trim().toLowerCase(); + return t ? t : null; + }; + const website = str("website"); + const parts = [ + normalizeUuid(o.lead_id) ?? null, + normalizeUuid(o.contact_id) ?? null, + website ? normalizeDomain(website) ?? website : null, + str("name"), + str("location"), + ]; + return parts.some((p) => p !== null) ? JSON.stringify(parts) : null; +} + +/** Re-point `ref.input_indexes` at the CURRENT caller's `lead_refs`. + * + * The qualify key is order-insensitive on purpose — the same refs in another + * order are the same approved work — so a reordered retry dedupes onto the + * ORIGINAL job, whose `input_indexes` describe the ORIGINAL order. Relaying + * them then maps every skipped-item verdict onto the wrong company for this + * caller: retrying `[B, A]` after `[A, B]` reports A at index 0. + * + * Remap by ref identity where every item resolves. Where any item cannot be + * matched — the backend echo is missing or shaped differently than assumed — + * null the indexes for ALL items rather than relay a mix of correct and + * stale ones. A missing index is a gap; a wrong index is a false statement + * about which company was skipped. */ +export function remapInputIndexes( + items: McpJobItem[], + refs: unknown +): { items: McpJobItem[]; remapped: boolean } { + const list = Array.isArray(refs) ? refs : []; + if (list.length === 0) return { items, remapped: false }; + const byIdentity = new Map(); + list.forEach((ref, i) => { + const key = refIdentity(ref); + if (!key) return; + const at = byIdentity.get(key); + if (at) at.push(i); + else byIdentity.set(key, [i]); + }); + + const next: McpJobItem[] = []; + let ok = true; + for (const item of items) { + const ref = item.ref; + if (!ref || ref.input_indexes == null) { + next.push(item); + continue; + } + const key = + refIdentity(ref.requested_as) ?? + refIdentity({ lead_id: ref.lead_id ?? undefined }); + const found = key ? byIdentity.get(key) : undefined; + if (!found) { + ok = false; + break; + } + next.push({ ...item, ref: { ...ref, input_indexes: found } }); + } + + if (!ok) { + return { + items: items.map((item) => + item.ref && item.ref.input_indexes != null + ? { ...item, ref: { ...item.ref, input_indexes: null } } + : item + ), + remapped: false, + }; + } + return { items: next, remapped: true }; +} + +/** Canonicalize a SET-valued list for hashing: sorted AND de-duplicated. + * Every list the backend treats as a set (sectors, locations, channels, + * contact_titles, exclude_lead_ids, refs) must go through this — sorting + * alone leaves `["Dallas","Dallas"]` and `["Dallas"]` hashing differently + * even though they request identical work, so a retry that happens to dedupe + * presents a new key and re-launches a paid job. */ +export function canonicalSet(values: unknown): unknown[] { + // The server does not validate inputSchema before dispatch, so a set-shaped + // field can arrive as a bare scalar (`channels: "email"`). Wrap it instead + // of calling .map on a string — that threw a TypeError while DERIVING the + // key, i.e. before the caller even saw a quote. + const list = + values === undefined || values === null + ? [] + : Array.isArray(values) + ? values + : [values]; + return [...new Set(list.map((v) => JSON.stringify(v)))] + .sort() + .map((v) => JSON.parse(v)); +} + +/** Coerce the array-typed params of a job tool into arrays, ONCE, before any + * other code touches them. + * + * The MCP server does not validate `inputSchema` before dispatch, so an agent + * can send any array field in its natural singular form — `channels: "email"`, + * `contact_titles: "Owner"`, `lead_refs: {website: "acme.com"}`. Every site + * that later does `.map` / `.join` / `.length` on those then throws a + * TypeError, and on the paid path that happens BEFORE the spend gate, so the + * caller gets a crash instead of the promised quote. + * + * Normalizing at the entry point fixes the whole class at once, rather than + * hardening each consumer separately. Non-array, non-null values are wrapped; + * null/undefined are left alone so `?? []` defaults still apply. */ +export function coerceArrayParams>( + params: T, + keys: readonly (keyof T)[] +): T { + const out = { ...params }; + for (const key of keys) { + const v = out[key]; + if (v !== undefined && v !== null && !Array.isArray(v)) { + out[key] = [v] as T[keyof T]; + } + } + return out; +} + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Lower-case a UUID-shaped id so casing alone never forks an idempotency + * key — the backend resolves `A1B2…` and `a1b2…` to the same record. A value + * that is not UUID-shaped is only trimmed, since we cannot assume the backend + * folds case for arbitrary identifiers. */ +/** A caller can fill a schema-`required` string with `""` — the server does not + * validate schemas before dispatch. `??` treats that as an explicit value, so a + * blank id would ship AS the idempotency key: if the backend reads blank as + * absent, a timeout retry launches a second paid job; if it reads blank as a + * key, unrelated blank-key approvals dedupe onto each other. Blank is missing. */ +export function presentRequestId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +/** True when a value is shaped like a Leadbay UUID. Lets a caller tell a + * lead id apart from a website or a company name in an untyped ref. */ +export function isUuidShaped(value: unknown): boolean { + return typeof value === "string" && UUID_RE.test(value.trim()); +} + +export function normalizeUuid(value: unknown): string | null { + if (typeof value !== "string") return null; + const v = value.trim(); + if (!v) return null; + return UUID_RE.test(v) ? v.toLowerCase() : v; +} + +/** Canonicalize a set of ids: UUID-folded, then sorted + deduped. */ +export function canonicalIdSet(values: unknown): string[] { + const list = + values === undefined || values === null + ? [] + : Array.isArray(values) + ? values + : [values]; + return canonicalSet( + list.map(normalizeUuid).filter((v): v is string => !!v) + ) as string[]; +} + +/** Canonicalize a set of free-text labels (contact titles, sectors): trimmed + * and lower-cased before dedupe, because the backend matches them + * semantically. `["Owner"]` and `["owner "]` request identical work, so a + * retry that re-cased them must not derive a new key and re-spend. */ +export function canonicalLabelSet(values: unknown): string[] { + const list = + values === undefined || values === null + ? [] + : Array.isArray(values) + ? values + : [values]; + return canonicalSet( + list + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim().toLowerCase()) + .filter(Boolean) + ) as string[]; +} + +/** Collapse the several shapes that all mean "nothing specified" to one. + * An omitted `filters`, `{}`, and `{locations: []}` request the same search, + * so they must hash identically or a retry that materializes an empty object + * launches a second paid job. */ +export function canonicalOptionalObject( + value: Record | null | undefined +): Record | null { + if (!value) return null; + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (v === undefined || v === null) continue; + if (Array.isArray(v) && v.length === 0) continue; + out[k] = v; + } + return Object.keys(out).length === 0 ? null : out; +} + +/** Recursively canonicalize a value for hashing: object keys sorted at every + * depth so property ORDER never forks a key, arrays left in place (order can + * be meaningful — callers pass set-shaped lists through canonicalSet). Plain + * JSON.stringify is not enough: an agent that rebuilds `example_lead` or + * `filters` with the properties in a different order would otherwise derive a + * different key for the same approved search and re-launch a paid job. */ +export function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + const out: Record = {}; + for (const k of Object.keys(value as Record).sort()) { + out[k] = canonicalize((value as Record)[k]); + } + return out; + } + return value; +} + +/** Stable idempotency key derived from an approved batch's own shape. + * SHA-256 truncated to 128 bits — a 32-bit digest collided in practice, and a + * collision here redirects one paid approval onto a different job. Nothing + * time-based goes into `shape`: a retry of the same approval must dedupe even + * if it lands the next day. */ +export function derivedKey(prefix: string, shape: unknown): string { + // Canonicalize HERE rather than at each call site, so neither tool can + // regress by hashing a hand-built string again. + const serialized = + typeof shape === "string" ? shape : JSON.stringify(canonicalize(shape)); + return `${prefix}-${createHash("sha256").update(serialized).digest("hex").slice(0, 32)}`; +} + +/** LEADBAY_MOCK=1 journals writes and answers the generic + * `{mocked, would_call}` envelope instead of a real `{job_id}`. Without a + * guard the submit falls through to polling `/mcp/jobs/undefined`, which has + * no fixture — so the repo's offline dry-run mode died on any non-dry_run + * call. Return the write preview instead. */ +export function mockedSubmitPreview( + submit: unknown, + tool: string, + region: string +): Record | null { + const s = (submit ?? {}) as Record; + if (typeof s.job_id === "string" && s.job_id) return null; + // Only claim "mocked" when mock mode is actually on. A REAL 2xx submit that + // came back without a job_id (backend contract drift, a proxy eating the + // body) must fail loudly: reporting a successful no-submit envelope would + // hide a production job the client cannot poll, and would tell the user + // LEADBAY_MOCK=1 when it is not set. + if (process.env.LEADBAY_MOCK !== "1") { + throw { + error: true, + code: "MALFORMED_SUBMIT_RESPONSE", + message: `${tool}: the submit succeeded but the response carried no job_id, so the job cannot be polled.`, + hint: "The job may still be running server-side. Do not re-submit blindly — reuse the same request_id so a retry dedupes instead of double-spending.", + }; + } + return { + mocked: true, + tool, + submitted: false, + would_call: s.would_call ?? null, + note: "LEADBAY_MOCK=1 — the job was not submitted, so there is no job to poll.", + region, + }; +} + +/** Sort a snapshot's items into the envelope every delivery tool returns: + * full leads for delivered/degraded, compact skip records for the rest. */ +export function splitItems(snapshot: McpJobSnapshot): { + leads: McpJobItem[]; + skipped: McpJobItem[]; +} { + const leads: McpJobItem[] = []; + const skipped: McpJobItem[] = []; + for (const item of snapshot.items) { + if (item.status === "skipped") skipped.push(item); + else leads.push(item); + } + return { leads, skipped }; +} + +/** Drop undefined values so the wire body only carries what the caller set + * (backend uses explicitNulls=false; absent and null are equivalent). */ +export function compactBody( + body: Record +): Record { + return Object.fromEntries( + Object.entries(body).filter(([, v]) => v !== undefined) + ); +} + +/** Country-level location values are silently useless: the backend excludes + * countries from admin-area search (product#3885), so the trigram resolver + * falls through to an arbitrary same-named town ("France" → the commune of + * Francs; "United States" → Statesboro) and the whole job is fenced to one + * village. In live E2E evals 4/4 agents passed a country label despite the + * description saying not to — prose does not prevent this, so the tool + * rejects it with a named, actionable error (tracked backend-side in + * product#3939). */ +const COUNTRY_ALIASES = [ + "united states", "united states of america", "usa", "us", "america", + "etats unis", "etats unis d amerique", "france", "fr", "french republic", + "republique francaise", +]; + +/** Country names that are ALSO a legitimate administrative fence — but only + * inside ONE universe, which is why these are keyed by region rather than + * subtracted globally. Each universe is single-country, so a name that is a + * state in the US universe is nothing but a country in the French one: + * + * - `Georgia` is a US state before it is a country, and one of the most + * common state fences a US account will ask for. On a FRANCE account it + * can only mean the country, and must still be rejected. + * - The French overseas regions and collectivities each carry their own + * ISO 3166-1 entry, so a comprehensive country list swallows every one of + * them — while "leads in Martinique" is exactly the kind of regional fence + * this parameter exists for. On a US account they are foreign countries. + * + * Municipality collisions (`Lebanon`, `Peru`, `Mexico`, … are all US town + * names) are deliberately NOT exempted: a bare town name identical to a + * country is genuinely ambiguous, and the rejection is loud and recoverable — + * `Lebanon, Kentucky` folds to a two-word key that never matches. A silent + * fence to one village is the failure this guard exists to prevent. */ +const SUBNATIONAL_EXEMPTIONS: Record> = { + us: new Set(["georgia", "georgie"].map(countryKey)), + fr: new Set( + [ + "guadeloupe", "martinique", "reunion", "mayotte", + "french guiana", "guyane francaise", + "new caledonia", "nouvelle caledonie", + "french polynesia", "polynesie francaise", + "saint martin", "saint barthelemy", "saint pierre and miquelon", + "saint pierre et miquelon", "wallis and futuna", "wallis et futuna", + ].map(countryKey), + ), +}; + +/** Union of every region's exemptions — the fallback when the caller's region + * is unknown. Deliberately permissive: without a region we cannot tell a + * legitimate state fence from a foreign country, and wrongly REJECTING a + * correct search is the louder failure. */ +const ALL_EXEMPTIONS: ReadonlySet = new Set( + Object.values(SUBNATIONAL_EXEMPTIONS).flatMap((s) => [...s]), +); + +function exemptionsFor(region?: string): ReadonlySet { + const key = typeof region === "string" ? region.trim().toLowerCase() : ""; + return SUBNATIONAL_EXEMPTIONS[key] ?? ALL_EXEMPTIONS; +} + +/** Every ISO 3166-1 country name, in English and French, folded to the same + * comparison key as the input. Built from `Intl.DisplayNames` rather than a + * hand-kept list: the two-country allowlist this replaces let `Canada`, + * `United Kingdom`, `Germany` and every other country through to the same + * silent same-named-town fencing it was written to stop. + * + * Two-letter codes are NOT added wholesale — they collide head-on with US + * state abbreviations (`CA` California, `DE` Delaware, `IN` Indiana). The + * only codes here are the two deliberate aliases in COUNTRY_ALIASES. */ +function buildCountryLocationValues(): Set { + const values = new Set(COUNTRY_ALIASES.map(countryKey)); + try { + const A = "A".charCodeAt(0); + const displays = ["en", "fr"].map( + (locale) => + new Intl.DisplayNames([locale], { type: "region", fallback: "none" }), + ); + for (let i = 0; i < 26; i++) { + for (let j = 0; j < 26; j++) { + const code = String.fromCharCode(A + i) + String.fromCharCode(A + j); + for (const display of displays) { + const name = display.of(code); + if (!name || name === code) continue; + values.add(countryKey(name)); + } + } + } + } catch { + // A Node built without full ICU yields no region names. Falling back to + // the explicit aliases keeps the originally-observed failure covered + // rather than throwing at import time. + } + // Exemptions are NOT subtracted here — they are region-scoped and applied + // per call. Baking them in would exempt a French region on a US account. + return values; +} + +const COUNTRY_LOCATION_VALUES = buildCountryLocationValues(); + +/** Fold a location label to a comparison key so spelling variants collapse: + * strips accents, punctuation (so `U.S.` and `U.S` both become `us`), a + * leading article (`the United States`, `la France`, `les États-Unis`), and + * collapses whitespace. Exact-matching the raw string let every one of those + * through to the silent same-named-town fencing this guard exists to stop. */ +function countryKey(raw: string): string { + return raw + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + // Hyphens/underscores separate words; dots and apostrophes do not (so + // "U.S" folds to "us", while "etats-unis" stays two words). + .replace(/[-_,]/g, " ") + // French elides its article onto the noun with no space — "l'Allemagne", + // "l'Espagne" — so the space-anchored article strip below never fires and + // the label folded to "lallemagne". Drop the elided article first, before + // the apostrophe itself is deleted. Anchored, so "U.S" still folds to "us". + .replace(/^\s*(l|d)['’]\s*/, "") + .replace(/['’.]/g, "") + .replace(/\s+/g, " ") + .trim() + // Longest article first, so "les" is never matched as "le" + leftover. + .replace(/^(les|the|la|le|l)\s+/, "") + .trim(); +} + +/** `exclude_lead_ids` accepts at most 500 ids. The shortfall top-up is where + * this bites: a paid run may examine up to `exploration_cap`'s ceiling + * (min(20n, 1000)) candidates, so "exclude everything already seen" overflows + * the cap and the backend refuses the whole call — the one call that exists + * to close a gap the user already paid toward. Reject it here, carrying the + * bounding rule, rather than letting an opaque 400 surface after the spend. + * + * `novelty: org` already excludes prior DELIVERIES, so delivered ids are the + * redundant half of the list and dropping them is usually enough to fit. */ +export const MAX_EXCLUDE_LEAD_IDS = 500; + +/** `canonicalIdSet` maps a non-string to null and drops blanks. That is right + * for hashing, but the canonical list is now what goes ON THE WIRE — so a + * silent drop means the paid search runs WITHOUT an exclusion the caller + * asked for, and can reselect and charge for exactly the lead they were + * trying to skip. Refuse the list instead of quietly shrinking it. */ +export function rejectMalformedExclusions(ids: unknown): void { + if (ids === undefined || ids === null) return; + const list = Array.isArray(ids) ? ids : [ids]; + const bad: string[] = []; + list.forEach((v, i) => { + if (typeof v !== "string") { + bad.push(`${i} (${v === null ? "null" : typeof v})`); + } else if (!v.trim()) { + bad.push(`${i} (blank)`); + } + }); + if (bad.length === 0) return; + throw { + error: true, + code: "INVALID_EXCLUDE_LEAD_ID", + message: `exclude_lead_ids has ${bad.length} entr${bad.length === 1 ? "y" : "ies"} that is not a lead id: ${bad.join(", ")}.`, + hint: "Drop or fix those entries and re-call — every entry must be a non-blank lead id string. Silently skipping them would run the search without an exclusion you asked for, and could re-deliver and charge for that exact lead.", + }; +} + +export function rejectOversizedExclusions(ids: unknown): void { + if (ids === undefined || ids === null) return; + // Count what would actually be SENT. That is only true because the submit + // body posts `canonicalIdSet(exclude_lead_ids)` too — counting the canonical + // list while wiring the raw one would clear a 600-entry array that dedupes + // to 400 and then let the backend refuse it anyway. + const unique = canonicalIdSet(ids); + if (unique.length <= MAX_EXCLUDE_LEAD_IDS) return; + throw { + error: true, + code: "TOO_MANY_EXCLUSIONS", + message: `exclude_lead_ids carries ${unique.length} ids — the maximum is ${MAX_EXCLUDE_LEAD_IDS}.`, + hint: "Drop the DELIVERED ids first: novelty:'org' already excludes those. Send the examined-but-rejected ones (disqualified + skipped), most recent first, capped at 500.", + }; +} + +export function rejectCountryLocations( + locations: unknown, + region?: string +): void { + if (locations === undefined || locations === null) return; + // Exemptions depend on WHICH universe is asking: `Georgia` is a state on a + // US account and nothing but a country on a French one, and the French + // overseas regions are the mirror image. A process-wide exemption set let + // each one bypass the guard on the wrong side and reach the backend. + const exempt = exemptionsFor(region); + // The server does not validate the schema before dispatch, so an agent can + // send `filters.locations` as a bare string. Treating a non-array as "no + // locations" let a scalar "United States" sail past the guard and reach the + // backend, reintroducing exactly the silent same-named-town fencing this + // exists to stop. Normalize to a one-item list instead of returning. + const list = Array.isArray(locations) ? locations : [locations]; + for (const loc of list) { + if (typeof loc !== "string") continue; + const key = countryKey(loc); + if (!exempt.has(key) && COUNTRY_LOCATION_VALUES.has(key)) { + throw { + error: true, + code: "COUNTRY_LEVEL_LOCATION", + message: `filters.locations value "${loc}" is country-level — it would silently fence the search to a same-named town, not the whole country.`, + hint: "Whole-country intent = OMIT filters.locations entirely (each universe is single-country). Use city/state/region names for narrower fences. If you meant a town that shares the name, qualify it with its state or region (e.g. \"Lebanon, Kentucky\").", + }; + } + } +} + +/** Tolerant reader for the search `filters` object. The RESULT payload's + * company shape (`employees: {min, max, known}`) teaches agents a nested + * employees object, and in live evals 2/2 cold agents passed exactly that + * on input — which the backend rejects with an unhelpful deserialization + * 400. Map it (and the camelCase spellings) onto the flat wire keys + * instead of failing the whole ask. */ +export function normalizeSearchFilters( + filters: Record | undefined +): Record | undefined { + if (filters == null) return undefined; + const { employees, employeesMin, employeesMax, ...rest } = filters; + const out: Record = { ...rest }; + if (out.employees_min == null) { + out.employees_min = employees?.min ?? employees?.employees_min ?? employeesMin; + } + if (out.employees_max == null) { + out.employees_max = employees?.max ?? employees?.employees_max ?? employeesMax; + } + if (out.employees_min == null) delete out.employees_min; + if (out.employees_max == null) delete out.employees_max; + // The server does not validate inputSchema before dispatch, so an agent can + // send `locations: "Dallas"` where the backend expects an array. Wrap a bare + // string in a one-item list — the same tolerance rejectCountryLocations + // already applies — instead of forwarding a scalar that 400s on + // deserialization. + for (const key of ["sectors", "locations"]) { + const v = out[key]; + if (typeof v === "string") out[key] = v.trim() ? [v] : undefined; + if (out[key] === undefined) delete out[key]; + } + return out; +} + +export function clampWaitSeconds( + requested: number | undefined, + fallback: number +): number { + if (requested == null || Number.isNaN(requested)) return fallback; + return Math.min(Math.max(requested, 0), 180); +} diff --git a/packages/core/src/composite/find-new-leads.ts b/packages/core/src/composite/find-new-leads.ts new file mode 100644 index 00000000..9ac05a80 --- /dev/null +++ b/packages/core/src/composite/find-new-leads.ts @@ -0,0 +1,448 @@ +// leadbay_find_new_leads — POST /mcp/search + poll GET /mcp/jobs/{id} +// +// One ask -> n net-new companies matching an ICP, optionally AI-qualified +// against the org's frozen intelligence snapshot, optionally with the right +// contact + purchased channels. Free by default (qualify:false, channels:[]). +// Submit validates synchronously (<1s, every input error is a 400 naming the +// field); results stream per-item and are collected here with a short poll +// window, handing back a job_id + leadbay_lead_job_status when the job +// outlives the window. +import type { LeadbayClient } from "../client.js"; +import type { Tool, ToolContext } from "../types.js"; +import { + clampWaitSeconds, + collectJobSnapshot, + canonicalSet, + coerceArrayParams, + canonicalIdSet, + canonicalLabelSet, + canonicalOptionalObject, + derivedKey, + presentRequestId, + mockedSubmitPreview, + compactBody, + normalizeSearchFilters, + rejectCountryLocations, + rejectMalformedExclusions, + rejectOversizedExclusions, + snapshotAfterSubmit, + splitItems, + TERMINAL_JOB_STATES, + waitForJob, + type McpDryRunResponse, + type McpSubmitResponse, +} from "./_mcp-job-helpers.js"; +import { leadbay_find_new_leads as FIND_NEW_LEADS_DESCRIPTION } from "../tool-descriptions.generated.js"; + +interface FindNewLeadsParams { + query?: string; + example_lead?: { + name?: string; + description?: string; + location?: string; + employees?: number; + }; + filters?: { + sectors?: string[]; + locations?: string[]; + employees_min?: number; + employees_max?: number; + }; + count: number; + qualify?: boolean; + min_ai_score?: number; + contact_titles?: string[]; + title_gate?: "strict" | "prefer"; + channels?: Array<"email" | "phone">; + exclude_lead_ids?: string[]; + novelty?: "org" | "none"; + max_cost?: number; + exploration_cap?: number; + request_id: string; + lang?: string; + confirm?: boolean; + dry_run?: boolean; + wait_seconds?: number; +} + +const DEFAULT_WAIT_SECONDS = 45; + +/** Canonicalize the filter lists that are unordered SETS of free-text labels + * to the backend, so a retry listing the same sectors/locations in another + * order — or with different casing — still derives the same idempotency key. + * Object key order is handled by canonicalize() inside derivedKey; empty + * shapes are collapsed by canonicalOptionalObject at the call site. */ +function sortFilterLists( + filters: Record | undefined +): Record | null { + if (!filters) return null; + const out: Record = { ...filters }; + for (const key of ["sectors", "locations"]) { + if (Array.isArray(out[key])) { + out[key] = canonicalLabelSet(out[key] as string[]); + } + } + return out; +} + +export const findNewLeads: Tool = { + name: "leadbay_find_new_leads", + annotations: { + title: "Find new leads (net-new ICP search)", + readOnlyHint: false, + // The tool CAN bill (qualify:true and/or channels) and records deliveries + // in the org novelty ledger, so it advertises destructive like the other + // paid composites — annotations are static and must describe the worst + // case, not the default. The free path is protected in execute() instead: + // a paid call is withheld until `confirm: true`. + destructiveHint: true, + // The mandatory request_id dedups: re-submitting the same request returns + // the SAME live job instead of double-spending. + idempotentHint: true, + openWorldHint: true, + }, + write: true, + description: FIND_NEW_LEADS_DESCRIPTION, + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: + "Natural-language ICP ask. Matches topic VOCABULARY — can surface vendors of a product as easily as buyers of it. Prefer example_lead; use query only when the user's wording carries signal an example can't.", + }, + example_lead: { + type: "object", + description: + "A FICTIONAL typical ideal customer used as a look-alike seed — the highest-leverage input. Put everything in `description` (registry 'About Us' style, what the company IS); leave `name` unset (a distinctive invented name pulls matches toward name-lookalikes).", + properties: { + name: { type: "string" }, + description: { type: "string" }, + location: { type: "string" }, + employees: { type: "number" }, + }, + additionalProperties: false, + }, + filters: { + type: "object", + description: + "HARD constraints (the seed only shapes ranking). Sector/location labels resolve at submit; an unresolvable value is a 400 naming it.", + properties: { + sectors: { type: "array", items: { type: "string" } }, + locations: { type: "array", items: { type: "string" } }, + employees_min: { type: "number" }, + employees_max: { type: "number" }, + }, + additionalProperties: false, + }, + count: { + type: "number", + description: + "Target DELIVERED leads, 1-50. With qualify:true this means n SURVIVORS of qualification, not n candidates examined.", + }, + qualify: { + type: "boolean", + description: + "Run fresh AI qualification and drop candidates scoring below min_ai_score. PAID: ~94 cost_cents per candidate EXAMINED (survivor or not). Default false (free).", + }, + min_ai_score: { + type: "number", + description: + "Disqualification floor on the [-30,+30] qualification DELTA (not the 0-100 fit score). Default 0. Lower to -30 to keep every evaluated lead with its evidence.", + }, + contact_titles: { + type: "array", + items: { type: "string" }, + description: + "Wanted decision-maker titles (max 10), matched semantically cross-language.", + }, + title_gate: { + type: "string", + enum: ["strict", "prefer"], + description: + "strict = only leads with a matching known contact; prefer (default when contact_titles set) = matched first, rest flagged.", + }, + channels: { + type: "array", + items: { type: "string", enum: ["email", "phone"] }, + description: + "Contact channels to PURCHASE (email 25c, phone 250c, billed on success only). Empty = free identity tier.", + }, + exclude_lead_ids: { + type: "array", + items: { type: "string" }, + description: + "Caller-side novelty belt on top of the server-side one (max 500 ids — over that the call is refused, so drop the DELIVERED ids first: novelty:'org' already covers those, and the examined-but-rejected ones are what it misses).", + }, + novelty: { + type: "string", + enum: ["org", "none"], + description: + "org (default) = only companies NEW to the org (excludes org leads, lens members, CRM ids, prior MCP deliveries).", + }, + max_cost: { + type: "number", + description: + "Spend cap for the whole job in cost_cents. Defaults by plan tier (500/2000/5000). The job stops honestly at the cap (stop_reason max_cost).", + }, + exploration_cap: { + type: "number", + description: + "Max candidates the qualify gate may examine. Default min(3n,150), ceiling min(20n,1000).", + }, + request_id: { + type: "string", + description: + "REQUIRED idempotency key. Derive it from the ask (e.g. 'gyms-texas-2026-07-28'); REUSE the exact same value when retrying the same ask — a duplicate returns the SAME job instead of double-spending. Use a NEW value only for a genuinely new ask.", + }, + lang: { type: "string", description: "Output language (default: user's language)." }, + confirm: { + type: "boolean", + description: + "Explicit spend decision, required only for a PAID search (qualify:true and/or channels). true = the user approved the quote, go ahead. false = a veto (returns mode:'needs_confirmation', spends nothing). Omitted on a paid call → the tool withholds the submit and returns a free quote to show the user first. The default FREE search (no qualify, no channels) needs no confirm.", + }, + dry_run: { + type: "boolean", + description: + "Validate + worst-case cost estimate + quota forecast. No job, no spend. Use before the first PAID run of a session.", + }, + wait_seconds: { + type: "number", + description: + "How long to poll before returning (default 45, max 180, 0 = submit + one snapshot). Free searches usually finish inside the window; paid exploration can take minutes — the result then carries still_running:true and the job_id to check with leadbay_lead_job_status.", + }, + }, + required: ["count", "request_id"], + additionalProperties: false, + }, + execute: async ( + client: LeadbayClient, + params: FindNewLeadsParams, + ctx?: ToolContext + ) => { + // Unvalidated MCP args can arrive singular; coerce BEFORE the spend gate + // so a shape slip is never a TypeError in place of a quote. + params = coerceArrayParams(params, [ + "contact_titles", + "channels", + "exclude_lead_ids", + ]); + rejectCountryLocations(params.filters?.locations, client.region); + // Types first, then the cap: counting a list that still contains junk + // would size the cap against entries that were never going to be sent. + rejectMalformedExclusions(params.exclude_lead_ids); + rejectOversizedExclusions(params.exclude_lead_ids); + + // Same spend gate as leadbay_qualify_leads. The trigger differs: `qualify` + // defaults to FALSE here, so the default ask really is free and only an + // explicit qualify:true and/or requested channels costs money. When it + // does, the submit is withheld pending `confirm: true` and a real + // dry-run quote is returned instead. + const buysChannels = (params.channels?.length ?? 0) > 0; + const buysQualification = params.qualify === true; + const isPaid = buysQualification || buysChannels; + const vetoed = params.confirm === false; + const consented = !vetoed && params.confirm === true; + + // `request_id` is schema-`required`, but the server does not validate + // schemas before dispatch, so a caller can omit it and compactBody would + // drop the key entirely — leaving a confirmed PAID search with no + // idempotency handle, so a timeout + retry launches a second paid, + // novelty-claiming job. Synthesize a stable key from the approved search + // itself, exactly as the qualify path does. + const requestId = + presentRequestId(params.request_id) ?? + derivedKey( + "search-auto", + // Passed as an OBJECT: derivedKey canonicalizes recursively, so nested + // property order (example_lead, filters) can never fork the key. Fields + // with a documented backend default are canonicalized TO that default, + // so an approval that omits one and a retry that passes it explicitly + // derive the same key rather than launching a second paid, + // novelty-claiming job. + { + query: params.query ?? null, + example_lead: params.example_lead ?? null, + // Sector/location lists are unordered sets to the backend — sort + // them so a reordered retry still dedupes. + filters: canonicalOptionalObject(sortFilterLists(normalizeSearchFilters(params.filters))), + count: params.count ?? null, + qualify: params.qualify === true, + min_ai_score: params.min_ai_score ?? 0, + contact_titles: canonicalLabelSet(params.contact_titles), + title_gate: + params.title_gate ?? + ((params.contact_titles?.length ?? 0) > 0 ? "prefer" : null), + channels: canonicalSet(params.channels), + // Sorted so ordering alone never forks the key, but PRESENT — a + // top-up differing only by exclude_lead_ids is a different approved + // search, and hashing it the same would return the first job as a + // duplicate with the exclusions never applied. + exclude_lead_ids: canonicalIdSet(params.exclude_lead_ids), + novelty: params.novelty ?? "org", + max_cost: params.max_cost ?? null, + // Documented backend default is min(3n,150), so an omitted cap is + // canonicalized TO it — same principle as min_ai_score/novelty above. + // Otherwise an approval that omits the cap and a retry that passes the + // materialized default ask for identical work under different keys, + // and the retry escapes dedupe into a second paid, novelty-claiming + // job. An explicit non-default cap still hashes distinctly. + exploration_cap: + params.exploration_cap ?? + (typeof params.count === "number" && params.count > 0 + ? Math.min(3 * params.count, 150) + : null), + lang: params.lang ?? null, + } + ); + + const body = compactBody({ + query: params.query, + example_lead: params.example_lead, + filters: normalizeSearchFilters(params.filters), + count: params.count, + qualify: params.qualify, + min_ai_score: params.min_ai_score, + contact_titles: params.contact_titles, + title_gate: params.title_gate, + channels: params.channels, + // Wire the SAME list the cap guard counted and the idempotency key was + // derived from. Posting the raw array instead let a 600-entry list that + // dedupes to 400 clear the guard and still be refused by the backend. + // Kept undefined when absent so compactBody drops it rather than + // sending an empty array. + exclude_lead_ids: params.exclude_lead_ids + ? canonicalIdSet(params.exclude_lead_ids) + : undefined, + novelty: params.novelty, + max_cost: params.max_cost, + exploration_cap: params.exploration_cap, + request_id: requestId, + lang: params.lang, + dry_run: params.dry_run, + }); + + if (params.dry_run) { + const forecast = await client.request( + "POST", + "/mcp/search", + body + ); + return { + dry_run: true, + ...forecast, + region: client.region, + }; + } + + if (isPaid && !consented) { + const forecast = vetoed + ? null + : await client.request("POST", "/mcp/search", { + ...body, + dry_run: true, + }); + return { + mode: "needs_confirmation", + submitted: false, + vetoed, + paid_because: [ + buysQualification + ? "qualify: true (~94 cost_cents per candidate EXAMINED)" + : null, + buysChannels ? `channels requested: ${params.channels!.join(", ")}` : null, + ].filter(Boolean), + quote: forecast, + estimated_cost: forecast?.estimated_cost ?? null, + items_requested: forecast?.items_requested ?? null, + hint: vetoed + ? "confirm:false vetoed the spend — nothing was submitted. Re-call with confirm:true to proceed, or drop qualify/channels for a free search." + : "Show the user this worst-case quote and get an explicit go-ahead, then re-call with confirm:true. For a free search instead: omit qualify and channels.", + region: client.region, + }; + } + + // preSendSignal, NOT signal. While this POST is queued behind the client's + // concurrency slots nothing has been sent, so a cancel there is free and + // provably spends nothing — that is the window this closes. Once it is on + // the wire it is deliberately left to finish: aborting mid-flight would + // leave us unable to say whether the backend already committed the job, + // charged for it, and claimed novelty on the leads. + const submit = await client.request( + "POST", + "/mcp/search", + body, + { preSendSignal: ctx?.signal } + ); + const mocked = mockedSubmitPreview( + submit, + "leadbay_find_new_leads", + client.region + ); + if (mocked) return mocked; + const waitSeconds = clampWaitSeconds( + params.wait_seconds, + DEFAULT_WAIT_SECONDS + ); + // Every failure past this point must carry submit.job_id: the job exists + // and may be spending, and this handle is the only way back to it. + const snapshot = await snapshotAfterSubmit( + client, + submit.job_id, + waitSeconds, + ctx, + params.count + ); + + const done = TERMINAL_JOB_STATES.has(snapshot.job.state); + const { leads, skipped } = splitItems(snapshot); + return { + job_id: submit.job_id, + request_id: requestId, + duplicate_submit: submit.duplicate ?? false, + state: snapshot.job.state, + done, + summary: { + // Named items_requested (not `requested`) to match qualify_leads and + // the shared renderer, which reads summary.items_requested for the + // "delivered X of the Y asked" clause. + items_requested: submit.items_requested ?? params.count, + delivered: snapshot.funnel.delivered ?? 0, + delivered_callable: snapshot.funnel.delivered_callable ?? 0, + delivered_title_only: snapshot.funnel.delivered_title_only ?? 0, + degraded: snapshot.funnel.degraded ?? 0, + stop_reason: snapshot.funnel.stop_reason ?? null, + }, + funnel: snapshot.funnel, + leads, + skipped, + items_truncated: snapshot.items_truncated ?? false, + // Top-level, not only inside next_poll: on a TERMINAL job that truncated, + // next_poll used to be null, so the rendering rule telling the agent to + // fetch the rest with `since: next_since` named a cursor the response did + // not contain. The rows are paid for; the way to reach them cannot be + // conditional on the job still running. + next_since: snapshot.next_since ?? null, + cost: snapshot.cost, + estimated_cost: submit.estimated_cost, + explain: snapshot.explain, + still_running: !done, + // A finished job can still owe rows: truncation means the drain stopped + // early, so there is a follow-up action even when done is true. It is a + // page fetch, not a wait, hence suggested_wait_seconds 0. + next_poll: + done && !(snapshot.items_truncated ?? false) + ? null + : { + tool: "leadbay_lead_job_status", + job_id: submit.job_id, + // Hand the cursor forward so the follow-up poll continues + // INCREMENTALLY instead of re-reading (and re-rendering) the + // rows already delivered in this response. + since: snapshot.next_since ?? null, + suggested_wait_seconds: done ? 0 : 60, + }, + region: client.region, + }; + }, +}; diff --git a/packages/core/src/composite/lead-job-status.ts b/packages/core/src/composite/lead-job-status.ts new file mode 100644 index 00000000..1b868702 --- /dev/null +++ b/packages/core/src/composite/lead-job-status.ts @@ -0,0 +1,119 @@ +// leadbay_lead_job_status — GET /mcp/jobs/{id} +// +// Cumulative snapshot of a find_new_leads / qualify_leads job. Items are +// immutable once emitted; the optional `since` cursor pages only what's new +// since the last poll. Terminal projections are server-side: past the 30-min +// wall clock a job reads completed_partial(time_budget), past the 30-day TTL +// it reads expired — never an eternal `running`. +import type { LeadbayClient } from "../client.js"; +import type { Tool, ToolContext } from "../types.js"; +import { + clampWaitSeconds, + collectJobSnapshot, + splitItems, + TERMINAL_JOB_STATES, + waitForJob, +} from "./_mcp-job-helpers.js"; +import { leadbay_lead_job_status as LEAD_JOB_STATUS_DESCRIPTION } from "../tool-descriptions.generated.js"; + +interface LeadJobStatusParams { + job_id: string; + since?: string; + limit?: number; + wait_seconds?: number; +} + +export const leadJobStatus: Tool = { + name: "leadbay_lead_job_status", + annotations: { + title: "Poll a lead-delivery job", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + description: LEAD_JOB_STATUS_DESCRIPTION, + inputSchema: { + type: "object", + properties: { + job_id: { + type: "string", + description: + "The job_id returned by leadbay_find_new_leads or leadbay_qualify_leads.", + }, + since: { + type: "string", + description: + "Opaque cursor from a previous poll's next_since — returns only items emitted after it. Omit for the full snapshot.", + }, + limit: { + type: "number", + description: "Items per page, 1-100 (default 100; pages are auto-collected).", + }, + wait_seconds: { + type: "number", + description: + "0 (default) = instant snapshot. >0 = keep polling up to this many seconds until the job is terminal — use ~60 when the user asked to wait for results.", + }, + }, + required: ["job_id"], + additionalProperties: false, + }, + execute: async ( + client: LeadbayClient, + params: LeadJobStatusParams, + ctx?: ToolContext + ) => { + const waitSeconds = clampWaitSeconds(params.wait_seconds, 0); + const snapshot = + waitSeconds > 0 + ? await waitForJob( + client, + params.job_id, + waitSeconds, + ctx, + undefined, + params.since, + params.limit + ) + : await collectJobSnapshot( + client, + params.job_id, + params.since, + params.limit, + ctx?.signal + ); + + const done = TERMINAL_JOB_STATES.has(snapshot.job.state); + const { leads, skipped } = splitItems(snapshot); + return { + job_id: params.job_id, + state: snapshot.job.state, + done, + funnel: snapshot.funnel, + leads, + skipped, + // Surfaced so the renderer never presents a partial page set as the whole + // result: `leads` is a prefix, and next_since resumes it. + items_truncated: snapshot.items_truncated ?? false, + next_since: snapshot.next_since ?? null, + cost: snapshot.cost, + explain: snapshot.explain, + still_running: !done, + // Truncation leaves rows unread even on a finished job, so the follow-up + // action survives `done` — same rule as the two submit tools. + next_poll: + done && !(snapshot.items_truncated ?? false) + ? null + : { + tool: "leadbay_lead_job_status", + job_id: params.job_id, + // Same incremental handoff as the submit tools — following + // next_poll without the cursor re-reads the rows just returned. + since: snapshot.next_since ?? null, + suggested_wait_seconds: done ? 0 : 60, + }, + region: client.region, + }; + }, +}; diff --git a/packages/core/src/composite/qualify-leads.ts b/packages/core/src/composite/qualify-leads.ts new file mode 100644 index 00000000..60256304 --- /dev/null +++ b/packages/core/src/composite/qualify-leads.ts @@ -0,0 +1,532 @@ +// leadbay_qualify_leads — POST /mcp/qualify + poll GET /mcp/jobs/{id} +// +// "Qualify these companies I already have, and get me the right contact on +// each" in one server-side job. Refs can be lead ids, websites, name+location +// pairs, stable contact ids from prior results, or the prior_deliveries +// ledger selector. Every ref gets a per-item outcome — a bad ref never fails +// the job. Disqualified leads the org owns are DELIVERED with their negative +// evidence, never silently dropped. Repeat calls reuse every fresh cached +// stage and converge to near-zero cost. +import type { LeadbayClient } from "../client.js"; +import type { Tool, ToolContext } from "../types.js"; +import { + clampWaitSeconds, + collectJobSnapshot, + canonicalSet, + coerceArrayParams, + isUuidShaped, + normalizeUuid, + presentRequestId, + remapInputIndexes, + canonicalLabelSet, + derivedKey, + mockedSubmitPreview, + compactBody, + snapshotAfterSubmit, + splitItems, + TERMINAL_JOB_STATES, + waitForJob, + type McpDryRunResponse, + type McpSubmitResponse, +} from "./_mcp-job-helpers.js"; +import { normalizeDomain } from "./import-leads.js"; +import { leadbay_qualify_leads as QUALIFY_LEADS_DESCRIPTION } from "../tool-descriptions.generated.js"; + +interface QualifyLeadsParams { + lead_refs?: Array<{ + lead_id?: string; + website?: string; + name?: string; + location?: string; + contact_id?: string; + }>; + prior_deliveries?: { + job_id?: string; + since?: string; + limit?: number; + }; + qualify?: boolean; + contact_titles?: string[]; + title_gate?: "strict" | "prefer"; + channels?: Array<"email" | "phone">; + max_cost?: number; + request_id?: string; + lang?: string; + confirm?: boolean; + dry_run?: boolean; + wait_seconds?: number; +} + +const DEFAULT_WAIT_SECONDS = 45; + +/** Stable idempotency key for a paid batch the caller didn't key itself. + * Deterministic over the APPROVED BATCH ITSELF — refs, selector, paid flags, + * spend cap — and nothing time-based: a retry of the same approval must + * dedupe even if it lands after midnight or hours later. A genuinely + * different batch (different refs, channels, titles, or a raised max_cost + * after a stop_reason: max_cost) hashes differently and runs as a new job. + * A caller who wants a deliberate re-run of an identical batch passes an + * explicit request_id. */ +/** MCP args arrive unvalidated, and `coerceArrayParams` turns a scalar + * `lead_refs: "acme.com"` into `["acme.com"]` — a STRING where the schema + * promises an object. Read as an object that yields an all-null ref, so EVERY + * string ref canonicalizes identically: two different companies derive the + * same `qualify-auto-*` key and the second batch dedupes onto the first + * PAID job. The raw string is also posted as-is, which the backend 400s + * after this tool already promised a quote. + * + * Map the shorthand onto the shape the schema documents instead — a UUID is + * a lead_id, a domain is a website, anything else is a name — so the derived + * key and the submitted body agree and describe the company the caller meant. */ +function normalizeLeadRefs( + refs: QualifyLeadsParams["lead_refs"] +): QualifyLeadsParams["lead_refs"] { + if (!Array.isArray(refs)) return refs; + return refs.map((ref) => { + if (typeof ref !== "string") return ref; + const value = (ref as string).trim(); + if (!value) return ref; + if (isUuidShaped(value)) return { lead_id: value }; + return normalizeDomain(value) ? { website: value } : { name: value }; + }); +} + +/** MCP args are not schema-validated before dispatch, so `lead_refs` can carry + * `null`, a number, or an array where an object belongs. `normalizeLeadRefs` + * deliberately passes non-strings through untouched, so those entries reached + * `derivedRequestId`, where the first property access threw a raw TypeError — + * BEFORE the spend gate could return a quote. A malformed ref is a caller + * mistake, and the tool's contract is to answer with a named, actionable + * error rather than a stack trace or a silent drop: dropping would qualify + * and BILL a subset of the batch the user listed, without saying so. */ +/** Every identifying field the ref shape declares. Validated as a set rather + * than one-by-one so adding a field to `lead_refs` cannot silently reopen the + * crash: a new field left off this list is caught by the typecheck below. */ +const LEAD_REF_FIELDS = [ + "lead_id", + "website", + "name", + "location", + "contact_id", +] as const satisfies ReadonlyArray< + keyof NonNullable[number] +>; + +function rejectMalformedLeadRefs(refs: QualifyLeadsParams["lead_refs"]): void { + if (!Array.isArray(refs)) return; + const bad: string[] = []; + refs.forEach((ref, i) => { + if (ref === null || typeof ref !== "object" || Array.isArray(ref)) { + bad.push(`${i} (not an object)`); + return; + } + // Field TYPES, not just the container. `{website: 123}` cleared the object + // check and then died on `.trim()` while deriving the key — the same crash + // one level in. `undefined` and absent are both fine; anything present and + // non-string is not. + for (const field of LEAD_REF_FIELDS) { + const value = (ref as Record)[field]; + if (value !== undefined && typeof value !== "string") { + bad.push(`${i}.${field} (${value === null ? "null" : typeof value})`); + } + } + }); + if (bad.length === 0) return; + throw { + error: true, + code: "INVALID_LEAD_REF", + message: `lead_refs has ${bad.length} invalid entr${bad.length === 1 ? "y" : "ies"}: ${bad.join(", ")}.`, + hint: "Each ref is an object whose fields are STRINGS — {lead_id} | {website} | {name, location?} | {contact_id}. A bare string is accepted and reshaped; null, numbers, arrays and non-string field values are not. Fix or drop those entries and re-call.", + }; +} + +/** Trim + lowercase a value that SHOULD be a string, without trusting that it + * is. Non-strings fold to null rather than throwing, so an unvalidated caller + * cannot turn key derivation into a TypeError. */ +function text(value: unknown): string | null { + if (typeof value !== "string") return null; + const v = value.trim().toLowerCase(); + return v ? v : null; +} + +function derivedRequestId(params: QualifyLeadsParams): string { + // JSON-serialize each ref rather than joining raw values with delimiters. + // Field names alone were not enough: a value CONTAINING the delimiters + // forged a different ref's serialization, so {website:"acme~name=Paris"} and + // {website:"acme", name:"Paris~name="} hashed identically. JSON escapes the + // separators, so no value can impersonate a field boundary. + // Sorted AND de-duplicated: the backend collapses duplicate refs into one + // item, so a batch repeating a website and the same batch with it listed + // once are the same approved work. Leaving duplicates in forked the key, and + // a retry that happened to dedupe would then re-run the whole paid job. + const refs = canonicalSet( + (params.lead_refs ?? []).map((r) => { + // Defence in depth: rejectMalformedLeadRefs already guarantees every + // field is a string, but key derivation must not be the thing that + // crashes if a future caller reaches it without that guard. `text()` + // folds a non-string to null instead of throwing on .trim(). + const website = text(r.website); + return [ + // UUIDs are case-insensitive to the backend, so an uppercase id and + // its lowercase form are the same lead and must share a key. + normalizeUuid(r.lead_id), + normalizeUuid(r.contact_id), + // Normalize the website the SAME way the resolver does, so a pasted + // "https://Acme.com/" and a retry's "acme.com" resolve to one company + // AND to one key. Fall back to the trimmed/lowercased raw value when + // it is not domain-shaped, rather than dropping the field. + website ? normalizeDomain(website) ?? website : null, + text(r.name), + text(r.location), + ]; + }) + ); + // JSON the WHOLE shape for the same reason as the refs above: free-text + // values (contact_titles, lang) must not be able to forge a field boundary + // by containing a delimiter. + const shape = { + refs, + // The WHOLE selector, not just the job id: qualifying the first 50 of a + // delivery job and then the next 50 are different batches, and collapsing + // them to one key would make the second submit look like a duplicate and + // leave those refs unqualified. + prior: [ + // UUID-folded like the refs above: the backend resolves the same + // delivery job regardless of casing, so casing alone must not fork + // the key and re-run a paid batch. + normalizeUuid(params.prior_deliveries?.job_id), + params.prior_deliveries?.since ?? null, + params.prior_deliveries?.limit ?? null, + ], + // Canonicalize to the value the BACKEND will apply, so an approval that + // omits a field and a retry that passes that field's documented default + // derive the same key instead of launching a second paid job. + qualify: params.qualify !== false, + channels: canonicalSet(params.channels), + contact_titles: canonicalLabelSet(params.contact_titles), + // Same canonicalization as the search path: with contact_titles present + // the backend applies `prefer` when the field is omitted, so an approval + // that omits it and a retry that passes the materialized default describe + // identical work. Hashing the omission as null forked the key and let the + // retry escape dedupe into a second paid qualification / channel purchase. + title_gate: + params.title_gate ?? + ((params.contact_titles?.length ?? 0) > 0 ? "prefer" : null), + // The cap is part of the approval: raising it after a stop_reason:max_cost + // is a NEW approved run, and must not dedupe onto the capped job. + max_cost: params.max_cost ?? null, + // Same for the output language — re-running the batch in another language + // must not return the earlier job with evidence in the previous one. + lang: params.lang ?? null, + }; + return derivedKey("qualify-auto", shape); +} + +export const qualifyLeads: Tool = { + name: "leadbay_qualify_leads", + annotations: { + title: "Qualify + get the right contact on known leads", + readOnlyHint: false, + // Spends real money (fresh qualification, and email/phone reveals when + // channels are requested), same as bulk_qualify_leads / enrich-titles. + // Hosts and approval layers key their prompts off this flag, so a paid + // job submitter must not advertise itself as harmless. + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + write: true, + description: QUALIFY_LEADS_DESCRIPTION, + inputSchema: { + type: "object", + properties: { + lead_refs: { + type: "array", + description: + "Companies to qualify (max 500). Each ref needs at least one identifying field. Duplicate lead_ids collapse into one item.", + items: { + type: "object", + properties: { + lead_id: { type: "string", description: "Leadbay lead UUID." }, + website: { type: "string" }, + name: { type: "string" }, + location: { + type: "string", + description: "Disambiguates name-only refs (city/region).", + }, + contact_id: { + type: "string", + description: + "Stable lead_contact id from a prior result — enrichment then targets EXACTLY this person, never a re-match.", + }, + }, + additionalProperties: false, + }, + }, + prior_deliveries: { + type: "object", + description: + "Selector expanding the org's past MCP deliveries into refs — billed leads stay re-readable after result expiry. Combine with lead_refs or use alone.", + properties: { + job_id: { type: "string" }, + since: { type: "string", description: "ISO instant lower bound." }, + limit: { type: "number" }, + }, + additionalProperties: false, + }, + qualify: { + type: "boolean", + description: + "Fresh AI qualification (default true; ~94 cost_cents per lead needing fresh research+scoring, cache-free when a fresh dossier exists). Owned disqualified leads come back WITH their negative evidence.", + }, + contact_titles: { + type: "array", + items: { type: "string" }, + description: "Wanted decision-maker titles (max 10), matched semantically.", + }, + title_gate: { + type: "string", + enum: ["strict", "prefer"], + description: + "strict = only items with a matching known contact deliver a contact; prefer = matched first, rest flagged.", + }, + channels: { + type: "array", + items: { type: "string", enum: ["email", "phone"] }, + description: + "Channels to PURCHASE (email 25c, phone 250c, success-only, already-owned values are free). Empty = free identity tier.", + }, + max_cost: { + type: "number", + description: "Spend cap in cost_cents (plan-tier default when unset).", + }, + request_id: { + type: "string", + description: + "Recommended idempotency key — REUSE the same value when retrying the same batch so a retry returns the SAME job instead of re-spending.", + }, + lang: { type: "string", description: "Output language (default: user's language)." }, + confirm: { + type: "boolean", + description: + "Explicit spend decision for the PAID work (fresh qualification and/or channel purchases). true = the user approved the quote, go ahead. false = a veto (returns mode:'needs_confirmation', spends nothing). Omitted on a paid call → the tool withholds the submit and returns a free quote to show the user first. A fully FREE call (qualify:false and no channels) needs no confirm.", + }, + dry_run: { + type: "boolean", + description: + "Validate + worst-case cost + quota forecast. No job, no spend.", + }, + wait_seconds: { + type: "number", + description: + "How long to poll before returning (default 45, max 180, 0 = submit + one snapshot). Large or research-heavy batches can take minutes — the result then carries still_running:true and the job_id for leadbay_lead_job_status.", + }, + }, + additionalProperties: false, + }, + execute: async ( + client: LeadbayClient, + params: QualifyLeadsParams, + ctx?: ToolContext + ) => { + // Unvalidated MCP args can arrive singular (`channels: "email"`, + // `lead_refs: {website}`); coerce BEFORE the spend gate so a shape slip is + // never a TypeError in place of a quote. + params = coerceArrayParams(params, [ + "lead_refs", + "contact_titles", + "channels", + ]); + // …and a coerced scalar is a STRING inside that array, which every + // downstream reader treats as an object. Reshape before the spend gate so + // the quote, the idempotency key and the posted body all describe the same + // companies. + params = { ...params, lead_refs: normalizeLeadRefs(params.lead_refs) }; + // AFTER the string reshape (so a bare string is not called malformed) and + // BEFORE the spend gate and key derivation, both of which read ref fields. + rejectMalformedLeadRefs(params.lead_refs); + // Spend gate. `qualify` defaults to TRUE on the backend (~94 cost_cents per + // lead needing fresh research), so a bare call carrying only lead_refs is a + // PAID submit — up to 500 refs — that the user never approved. Prose in the + // description does not prevent this (the same lesson as the country-label + // rejection above and the enrich-titles consent gate, product#3848): the + // withhold has to live in code. + // + // FREE calls pass straight through: qualify:false with no channels buys + // nothing, so demanding consent there would be friction with no spend. + const buysChannels = (params.channels?.length ?? 0) > 0; + const buysQualification = params.qualify !== false; + const isPaid = buysQualification || buysChannels; + // An explicit confirm:false is a VETO — decline the spend outright, no + // quote round-trip. Distinct from confirm being absent (which earns a quote). + const vetoed = params.confirm === false; + const consented = !vetoed && params.confirm === true; + + // A paid submit without an idempotency key can be re-run by any timeout or + // agent retry, re-charging fresh qualification and channel purchases for + // the same refs. `request_id` is optional on this tool (unlike the search), + // so derive a stable one from the batch when the caller omits it: same refs + // + same paid flags on the same day = same key = backend dedupe. + // Blank is missing: a caller can fill this optional field with "" and `??` + // would ship it as the key — see presentRequestId. + const requestId = + presentRequestId(params.request_id) ?? + (isPaid ? derivedRequestId(params) : undefined); + + const body = compactBody({ + lead_refs: params.lead_refs, + prior_deliveries: params.prior_deliveries, + qualify: params.qualify, + contact_titles: params.contact_titles, + title_gate: params.title_gate, + channels: params.channels, + max_cost: params.max_cost, + request_id: requestId, + lang: params.lang, + dry_run: params.dry_run, + }); + + if (params.dry_run) { + const forecast = await client.request( + "POST", + "/mcp/qualify", + body + ); + return { dry_run: true, ...forecast, region: client.region }; + } + + if (isPaid && !consented) { + // Withhold the submit. Run the free dry_run so the user sees a REAL + // worst-case quote (not an invented estimate) before deciding — except on + // an explicit veto, where we spend nothing at all, not even a round-trip. + const forecast = vetoed + ? null + : await client.request("POST", "/mcp/qualify", { + ...body, + dry_run: true, + }); + return { + mode: "needs_confirmation", + submitted: false, + vetoed, + paid_because: [ + buysQualification + ? "qualify is on (backend default is true — pass qualify:false to keep it free)" + : null, + buysChannels ? `channels requested: ${params.channels!.join(", ")}` : null, + ].filter(Boolean), + quote: forecast, + estimated_cost: forecast?.estimated_cost ?? null, + items_requested: forecast?.items_requested ?? null, + hint: vetoed + ? "confirm:false vetoed the spend — nothing was submitted. Re-call with confirm:true to proceed, or qualify:false with no channels for a free pass." + : "Show the user this worst-case quote and get an explicit go-ahead, then re-call with confirm:true. For a free pass instead: qualify:false and no channels.", + region: client.region, + }; + } + + // preSendSignal, NOT signal — same reasoning as the /mcp/search submit: a + // cancel while QUEUED provably spent nothing, but an in-flight POST may + // already have committed and charged, so it is left to finish. + const submit = await client.request( + "POST", + "/mcp/qualify", + body, + { preSendSignal: ctx?.signal } + ); + const mocked = mockedSubmitPreview( + submit, + "leadbay_qualify_leads", + client.region + ); + if (mocked) return mocked; + const waitSeconds = clampWaitSeconds( + params.wait_seconds, + DEFAULT_WAIT_SECONDS + ); + // Every failure past this point must carry submit.job_id: the job exists + // and may be spending, and this handle is the only way back to it. + const snapshot = await snapshotAfterSubmit( + client, + submit.job_id, + waitSeconds, + ctx, + submit.items_requested + ); + + const done = TERMINAL_JOB_STATES.has(snapshot.job.state); + + // A duplicate submit returns the ORIGINAL job. Its ref.input_indexes + // describe the order THAT request used, and this key is deliberately + // order-insensitive, so a reordered retry would map each verdict onto the + // wrong company for the current caller. Re-point them at this caller's + // lead_refs, or null them when the mapping cannot be proven. + const indexed = (submit.duplicate ?? false) + ? remapInputIndexes(snapshot.items, params.lead_refs) + : { items: snapshot.items, remapped: true }; + const view = { ...snapshot, items: indexed.items }; + + return { + job_id: submit.job_id, + // Echo the key actually sent, so a retry can reuse it verbatim. + request_id: requestId ?? null, + duplicate_submit: submit.duplicate ?? false, + state: snapshot.job.state, + done, + summary: { + refs_submitted: params.lead_refs?.length ?? 0, + items_requested: submit.items_requested, + delivered: snapshot.funnel.delivered ?? 0, + delivered_callable: snapshot.funnel.delivered_callable ?? 0, + degraded: snapshot.funnel.degraded ?? 0, + resolved: snapshot.funnel.resolved ?? null, + not_in_universe: snapshot.funnel.not_in_universe ?? null, + stop_reason: snapshot.funnel.stop_reason ?? null, + }, + funnel: snapshot.funnel, + // Per-item outcomes in input order where known (ref.input_indexes maps + // back to the caller's lead_refs positions). Items carry the full + // QualifiedLead payload when delivered/degraded, and an honest + // status_reason (not_in_universe, low_confidence_identity, ...) when + // skipped — a skip is an ANSWER about that ref, not an error. + items: view.items, + // On a duplicate submit whose indexes could not be re-pointed at this + // caller's refs, input_indexes are null rather than stale — match items + // by `ref.requested_as` / `lead_id` instead. + input_indexes_remapped: (submit.duplicate ?? false) + ? indexed.remapped + : null, + // ...and the same outcomes pre-split, because the shared + // rendering/lead-delivery-table contract this tool's description + // mandates reads deliveries from `leads[]` and skips from `skipped[]`. + // Returning only `items` left an agent following the RENDER block with + // two empty tables; the sibling tools (find_new_leads, lead_job_status) + // both split. `items` stays for input-order per-ref mapping. + ...splitItems(view), + items_truncated: snapshot.items_truncated ?? false, + // Top-level, not only inside next_poll: a TERMINAL job that truncated had + // next_poll null, so the rendering rule pointing at `since: next_since` + // named a cursor the response did not carry. + next_since: snapshot.next_since ?? null, + cost: snapshot.cost, + estimated_cost: submit.estimated_cost, + explain: snapshot.explain, + still_running: !done, + // A finished job can still owe rows: truncation means the drain stopped + // early, so there is a follow-up action even when done is true. + next_poll: + done && !(snapshot.items_truncated ?? false) + ? null + : { + tool: "leadbay_lead_job_status", + job_id: submit.job_id, + // Hand the cursor forward so the follow-up poll continues + // INCREMENTALLY instead of re-reading (and re-rendering) the + // rows already delivered in this response. + since: snapshot.next_since ?? null, + suggested_wait_seconds: done ? 0 : 60, + }, + region: client.region, + }; + }, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 94ca5bb7..472d87aa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -122,6 +122,9 @@ import { bulkEnrichStatus } from "./composite/bulk-enrich-status.js"; import { adjustAudience } from "./composite/adjust-audience.js"; import { refinePrompt } from "./composite/refine-prompt.js"; import { seedCandidates } from "./composite/seed-candidates.js"; +import { findNewLeads } from "./composite/find-new-leads.js"; +import { qualifyLeads } from "./composite/qualify-leads.js"; +import { leadJobStatus } from "./composite/lead-job-status.js"; import { extendLens } from "./composite/extend-lens.js"; import { myLenses } from "./composite/my-lenses.js"; import { newLens } from "./composite/new-lens.js"; @@ -194,6 +197,8 @@ export { answerClarification, reportOutreach, reportFriction, sendFeedback, importLeads, importAndQualify, createCampaign, addLeadsToCampaign, removeLeadsFromCampaign, seedCandidates, extendLens, + // MCP-first lead delivery + findNewLeads, qualifyLeads, leadJobStatus, artifactKit, }; @@ -274,7 +279,20 @@ granularTools.forEach((t) => { }); // Composite read tools — always exposed (default agent surface). +// The MCP-first delivery tools depend on backend routes (`POST /1.6/mcp/search`, +// `POST /1.6/mcp/qualify`, `GET /1.6/mcp/jobs/{id}`) that are live on STAGING +// only — production returns 404. Shipping them unconditionally would hand every +// user tools that fail on their first call, so they stay behind an opt-in flag +// until the backend rollout lands. Remove this gate (and the flag) in the +// release that follows the backend deploy. +const MCP_FIRST_DELIVERY_ENABLED = + process.env.LEADBAY_MCP_LEAD_DELIVERY === "1"; + export const compositeReadTools: Tool[] = [ + // Poll surface for the MCP-first lead-delivery jobs (find_new_leads / + // qualify_leads). Read-only snapshot of a backend-owned job, gated with + // them since it is useless without a job to poll. + ...(MCP_FIRST_DELIVERY_ENABLED ? [leadJobStatus] : []), pullLeads, pullFollowups, followupsMap, @@ -337,6 +355,11 @@ export const compositeReadTools: Tool[] = [ // leadbay_new_lens / leadbay_adjust_audience). Without it the agent can only // probe sectors by trial-and-error or ask the user to read the web UI. listSectors, + // listLocations, same rationale on the geography axis. The delivery tools + // reject an unresolvable filters.locations with a 400 naming the value and + // send the agent here to look up the real admin area — a recovery path that + // only works if the lookup is reachable without LEADBAY_MCP_ADVANCED=1. + listLocations, // Billing / top-up tools — granular-shaped but ALWAYS exposed because // they're the canonical recovery path from a QUOTA_EXCEEDED wall. If // they were gated behind LEADBAY_MCP_ADVANCED=1 the agent would @@ -367,9 +390,31 @@ export const compositeReadTools: Tool[] = [ artifactKit, ]; +/** Every MCP-first delivery tool, regardless of the deployment gate above. + * The gate controls what a RUNNING server exposes; contract audits + * (WORKFLOWS.md, routing anti-triggers) must still see these as registered + * tools, or a temporary rollout flag would read as "this tool doesn't exist". */ +export const mcpFirstDeliveryTools: Tool[] = [ + // Write-tier: submits create server-side jobs that can spend money + // (qualification research, channel purchase) and claim novelty in the + // org's delivery ledger — same posture as the other spending composites. + // The FREE tier (qualify:false, channels:[]) is the default ask, and a paid + // call is withheld in code until `confirm: true`. + findNewLeads, + qualifyLeads, +]; + +/** All three delivery tools including the read-side poller — the ungated + * registry the contract audits read. */ +export const mcpFirstDeliveryAllTools: Tool[] = [ + ...mcpFirstDeliveryTools, + leadJobStatus, +]; + // Composite write tools — always-exposed in OpenClaw, gated in MCP behind // LEADBAY_MCP_WRITE=1 (the MCP server filters them out by default). export const compositeWriteTools: Tool[] = [ + ...(MCP_FIRST_DELIVERY_ENABLED ? mcpFirstDeliveryTools : []), bulkQualifyLeads, enrichTitles, adjustAudience, diff --git a/packages/core/src/tool-descriptions.generated.ts b/packages/core/src/tool-descriptions.generated.ts index 49b344d5..12491108 100644 --- a/packages/core/src/tool-descriptions.generated.ts +++ b/packages/core/src/tool-descriptions.generated.ts @@ -597,7 +597,7 @@ Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads be WHEN TO USE: when the user wants more qualified leads than what's currently shown, or when a lead looks promising in leadbay_pull_leads but has an empty \`qualification_summary\`. -WHEN NOT TO USE: to qualify a single specific lead — that's leadbay_qualify_lead (granular, advanced). +WHEN NOT TO USE: to qualify a single specific lead — that's leadbay_qualify_lead (granular, advanced). And NOT for companies the user names or lists themselves (CRM rows, websites, prior deliveries) — that's leadbay_qualify_leads (only if listed — it is release-gated; server-side batch with per-item verdicts and contact matching); this tool only walks the ACTIVE LENS top-down. This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`. @@ -1187,7 +1187,7 @@ Trigger phrases: "I want more leads on this lens", "extend the lens", "I need a **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools. -Do NOT use for: "show me today's leads" → \`leadbay_pull_leads\`; "narrow the audience" → \`leadbay_adjust_audience\`; "stop showing me X" → \`leadbay_refine_prompt\`. +Do NOT use for: "show me today's leads" → \`leadbay_pull_leads\`; "find me companies that " → \`leadbay_find_new_leads\` (only if listed); "narrow the audience" → \`leadbay_adjust_audience\`; "stop showing me X" → \`leadbay_refine_prompt\`. Prefer when: user has bigger appetite than the daily lens fill delivers — additive refill on same criteria @@ -1271,6 +1271,260 @@ If nothing matches cleanly, default to "pull leads now to see what's queued" — `; // endregion: leadbay_extend_lens +// region: leadbay_find_new_leads +export const leadbay_find_new_leads: string = `## WHEN TO USE + +Trigger phrases: "find me new leads", "find me N companies that ", "get me new prospects like ", "I need leads in that ", "search for companies that would buy ", "net-new leads outside my current pipeline", "we're entering — who should we target". + +**Memory:** recall + capture via \`leadbay_agent_memory_*\` tools. + +Do NOT use for: "show me today's leads / what's new today" → \`leadbay_pull_leads\`; "more leads like the ones in my lens" → \`leadbay_extend_lens\`; "qualify / vet these companies I have" → \`leadbay_qualify_leads\`; "qualify the top N of my batch" → \`leadbay_bulk_qualify_leads\`; "leads I should follow up with" → \`leadbay_pull_followups\`; "tell me about " → \`leadbay_research_lead_by_name_fuzzy\`. + +Prefer when: the user describes a target profile or names a count of NEW companies — craft the example_lead per the seed rules below BEFORE calling; never pass the user's raw sentence as query. + +Examples that SHOULD invoke this tool: +- "Find me 10 gyms around Dallas that would buy our flooring, with someone I can call." +- "Get me 20 new US SaaS companies, 50-2000 employees, with the VP People's email." +- "We're launching in Lyon — find 15 hotels that fit our ICP." + +Examples that should NOT invoke this tool (sound similar, route elsewhere): +- "Show me today's leads." +- "Which leads should I follow up with this week?" +- "Qualify these 40 websites from my spreadsheet." + +## RENDER (quick) + +3-col table of delivered leads in returned order: col 1 = 10-segment fit +bar + linked company · location · size; col 2 = why-fits ≤20 words; col 3 += contact + purchased channels. ALWAYS close with the honest funnel line +(matched/examined/delivered/stop reason/spend) — especially on 0 +delivered. Full algorithm below. + +--- + +Submit a net-new lead search: the backend matches an ICP seed against the full +company universe, applies hard filters, skips what the org already knows +(\`novelty: org\`), optionally qualifies against the org's own intelligence +(questions, tags, ideal buyer profile — frozen at submit), and optionally buys +contact channels. Polls up to \`wait_seconds\` (default 45); a longer job returns +\`still_running\` + \`next_poll\` — hand off to \`leadbay_lead_job_status\`. Jobs run +≤30 min, results kept 30 days. + +**Free vs paid — never spend silently.** Default (\`qualify: false\`, +\`channels: []\`) is FREE: company profile + fit score + cached research + +contact identity. Paid: \`qualify: true\` (~94 cost_cents per candidate +EXAMINED, capped by \`exploration_cap\`/\`max_cost\`) and \`channels\` (email 25c / +phone 250c, success-only). Enforced in code: a paid call is WITHHELD unless it +carries \`confirm: true\` — nothing is submitted and you get +\`mode: "needs_confirmation"\` with a real quote to show the user. Re-call with +\`confirm: true\` on their go-ahead ("spend / get their emails" counts). +\`confirm: false\` vetoes. Free needs no consent. **Preview free first** — +reshaping an off-profile seed is free, exploring it with \`qualify: true\` is +not. + +**Ad-hoc exclusions ("no chains") are enforced by NO tier** — \`filters\` has no +exclusion key, and \`qualify\` scores against the org's FROZEN questions and IBP, +which need not mention chains; the seed's inverse only shifts ranking. +Violators can survive, be paid for and be delivered — post-filter them yourself +and say the tier didn't enforce it. Durable enforcement → +\`leadbay_refine_prompt\`. + +### Crafting the \`example_lead\` seed — the input that decides result quality + +The \`example_lead\` is a FICTIONAL typical ideal customer, matched against real +registry/website descriptions — which state what a company **IS**, never what +is happening. Write it the same way or the matcher drifts. Every rule below is +measured: + +1. **Describe the BUYER, never the seller.** Ask: "would this company write a + check to my user?" A seed describing what the user SELLS surfaces their + *competitors and vendors*. If the product helps companies of type X serve + customers of type Y, the seed describes X — never Y. +2. **Put everything in \`description\`; leave \`name\` unset.** An invented brand + name pulls matching toward name-lookalikes — a seed named "Meridian + Analytics" returned five unrelated "Meridian" companies. +3. **Registry style, one sentence to ~250 chars.** Industry niche, business + model, what they sell or operate, who they serve, observable scale. Write + it like the first paragraph of their About-Us page. + - STRONG: "Operator of full-service fitness centers offering strength + areas, group classes and personal training to members across multiple + clubs." + - WEAK (generic): "A gym in Texas." + - WRONG (seller-side): "Supplier of durable modular flooring for gyms." +4. **No event language.** "hiring", "expanding", "just raised" are not + filters — registry descriptions never contain them, so they dilute the + profile. Purchase triggers belong in the org's qualification questions. +5. **No meta-markers.** Never "(example)", "(fictional)", "(placeholder)". +6. **Hard constraints go in \`filters\`, not prose — exact keys:** + \`sectors: string[]\`, \`locations: string[]\`, \`employees_min: number\`, + \`employees_max: number\`. FLAT numbers — nested \`employees: {min, max}\` + exists only in RESULT payloads. \`example_lead.employees\` does not filter. + \`locations\` take city/state/region names ("Dallas, TX", "Île-de-France"); + a country name is refused in code — whole-country intent = omit it. +7. **Prefer \`example_lead\` over \`query\`.** Query matches topic *vocabulary*: + "gyms that need durable flooring" surfaced flooring VENDORS, 0 delivered. + Use \`query\` only for signal an example can't express. +8. **One seed per buyer archetype.** An ask spanning two segments ("gyms and + warehouses") needs one search each with its own description and + \`request_id\` — a blended seed lands between the clusters and matches + neither. + + +**Parameter notes** +- \`request_id\` (REQUIRED) is the retry contract: SAME value retrying the same + ask (same live job, no double spend); NEW for a changed ask. Derive from ask + + archetype + date: \`gyms-dallas-2026-07-28\`. +- Never lower \`min_ai_score\` together with \`channels\` — that buys emails for + leads the AI just scored as junk. +- \`count\` ≤ 50; ≤3 active jobs/org; ≤10 submits/hour (429 + Retry-After — + wait, don't hammer). + +**Read the result honestly** — \`funnel\` + \`explain.scope_notes\` tell the story; +zero delivered gets a cause and a next move (rules in RENDERING). + +--- + +## RENDERING — delivery table + honest funnel line + +Render delivered leads (\`leads[]\`, i.e. items with status \`delivered\` or +\`degraded\`) as a markdown table **in the order returned**. Exactly three +columns. Then ALWAYS close with the funnel line (below) — even, especially, +when nothing was delivered. + +**Column 1 — Company** + +- Line 1: 10-segment fit bar in inline-code backticks from \`lead.fit.score\` + (0-100): \`filled = round(score/10)\`, glyphs \`▰\` filled / \`▱\` empty. When + \`lead.fit.components.qualification.available\` is true AND \`ai_score > 0\`, + replace the LAST filled segment with \`❖\` (AI-confirmed cap). When + \`fit.available\` is false, render \`▱▱▱▱▱▱▱▱▱▱\` and say "unscored" in col 2. + Never print the numeric score. +- Insert \`
\`, then: linked company name (target \`company.website\`, bare + hostnames get \`https://\`; unlinked plain text when absent) + \` · \` + short + location (City, ST / City, Country) + \` · \` + employees as \`min–max\` (omit + when \`employees.known\` is false). + +**Column 2 — Why it fits** + +- One sentence ≤ 20 words. Priority: \`fit.reasoning\` → gist of + \`company.description\` → top \`fit.components.qualification.matched_tags\`. +- If the item status is \`degraded\` or a requested channel failed, append the + honest flag in italics, e.g. *(email could not be sourced)*. + +**Column 3 — Contact** + +- \`[Name](linkedin) · role\` (linked name mandatory when a LinkedIn URL + exists; plain name otherwise). Below it, the PURCHASED channels only: + \`✉ value\` / \`☎ value\` inline as plain text (they auto-linkify). +- Channel statuses: \`delivered\` → show value; \`already_owned\` → value + + *(already yours)*; \`masked\` → "on file — reveal via channels"; + \`not_requested\` → omit; \`failed_*\` → *(no verified email/phone)*. +- No contact on the item (\`contact\` null): render \`—\` (title_gate \`prefer\` + delivers such rows flagged; say so in col 2 only when contact_titles were + requested). + +**The funnel line (mandatory, after the table):** + +One short line narrating the delivery honestly, from \`funnel\` + \`cost\` + +\`explain.scope_notes\`: + +> Matched N · examined E · qualified Q · disqualified D → **delivered X of +> the Y asked** · stopped: · spent C.CC. + +**Money: divide, then symbol.** Every amount (\`cost.spent\`, +\`estimated_cost.max\`, quotes) is \`cost_cents\` — divide by 100, two decimals, +so \`165\` renders \`1.65\`, NEVER \`165.00\`. Symbol from the account region: US +\`$\`, France \`€\`, unknown → bare. Never hard-code \`$\`: it misstates a charge. + +"of the Y asked" needs \`summary.items_requested\`, which submits carry but a +later \`leadbay_lead_job_status\` snapshot does not. Without it write **delivered +X** and stop — never back-fill Y from \`matched\`/\`examined\` (they count +candidates), never guess it. + +Plain-word stop reasons: \`target_reached\` → omit (success), \`pool_exhausted\` → +"ran out of matching candidates", \`max_cost\` → "hit the cost cap", \`quota\` → +"hit an org quota", \`time_budget\` → "hit the 30-min time budget". + +**When \`delivered\` is 0**: NEVER say just "no results". Render no table; give +the funnel line plus the relevant \`explain.scope_notes\` (the backend's own +diagnosis), then propose the concrete fix (reshape the seed per the craft +rules, lower \`min_ai_score\`, raise \`max_cost\`, drop a filter) as NEXT STEPS. + +**Weak batch**: when the BEST delivered \`fit.score\` is under 30, don't present +the table as an answer — open with "weak matches only", show at most the top 3, +propose reshaping the seed/filters first. The count was filled with +barely-better-than-random candidates. + +**Sanity-check every row**: (a) geo — \`city\`/\`region\` must sit inside any +requested fence; drop and call out leaks (same-named cities slip through). +(b) When \`explain.seed_strategy\` is \`text_match_exemplars\` (the standard FR +path), fit is calibrated for lead-to-lead distances, not exemplar centroids — +treat high scores skeptically and verify each row's \`description\`. + +**Skipped items** (\`skipped[]\`, qualify jobs mostly): render a compact second +table \`Ref → Outcome\` translating \`status_reason\` to plain words: +\`not_in_universe\` → "not in the Leadbay universe (import it first)", +\`low_confidence_identity\` → "couldn't safely match — check \`resolution.alternatives\`", +\`no_matching_contact\` → "no contact with the requested title", +\`disqualified\` → "evaluated: does not fit" (evidence is in the item when owned), +\`enrichment_failed\` → "channel could not be sourced (not billed)". + +**\`items_truncated\`**: rows are a PREFIX, not the batch. Say so, and offer +\`leadbay_lead_job_status(job_id, since: next_since)\` for the rest. + +**Hide from the user:** UUIDs (keep for tool calls, never render), cursors, +\`explain.model\`/\`intelligence_snapshot\`, raw \`distance\`/\`calibration\`, +\`seq\`/\`from_cache\`, empty arrays. + +## Linking a contact's name + +**MANDATORY: every contact name in your output — table cells, prose, headers, "Reach " callouts — MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception — the search URL below is always constructable from name + company.** + +URL priority (first applicable wins): + +1. **Real profile** — \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it). +2. **Constructed people-search** — \`https://www.linkedin.com/search/results/people/?keywords=++\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` °\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`°\` when a real \`linkedin_page\` was used. + +Never link a person's name to the company's LinkedIn page (and vice versa) — the two surfaces are different and conflating them quietly degrades the workflow. + + + +--- + +## NEXT STEPS — after a find_new_leads delivery + +**ALWAYS render NEXT STEPS via your host's next-step widget.** Use whichever is in your tool set — the NAME and SCHEMA differ: **\`ask_user_input_v0\`** (Claude chat / ChatGPT) takes plain-string options with \`type:"single_select"\`; **\`AskUserQuestion\`** (Claude cowork / Claude Code) takes object options \`{label, description}\` plus a required short \`header\` (≤12 chars) and \`multiSelect\`, NO \`type\` field, and never add an "Other" option (the host adds it). Match the schema to the tool you actually have — the wrong schema fails silently and you fall back to prose. Prose bullets are the fallback ONLY when NEITHER widget exists. Any turn that would end with a choice must be the widget — the widget IS the question. + +**If the tool result carries a \`next_steps\` object, that is the source of truth — use it directly.** Each option has a short \`.label\` (≤5 words) and a full \`.description\`. Map \`next_steps.options[]\` into your host widget VERBATIM and in order: for \`AskUserQuestion\` (cowork / Claude Code) pass each as \`{label, description}\`; for \`ask_user_input_v0\` (Claude chat / ChatGPT, string options only) pass each option's \`.description\` as the string (it's the full sentence). Do NOT reword, reorder, drop, or prose-ify them — they're built deterministically by the server so the offer (incl. the artifact option at position 0) fires every time. Fall back to the table below only when there is NO \`next_steps\` field. + +**One exception — skip the widget** when the user's original message contained a complete sequential instruction chain ("show me X and then do Y") AND all stated steps have been completed. In that case, end with STOP directly — the user stated their full plan and does not need a "what next?" prompt. +- Skip example: "Show me today's leads and then research the top one for me." → after research completes, emit STOP without the widget. +- Do NOT skip for: plain requests ("show me today's leads", "run my check-in"), recurring-language requests ("I do this every day"), or requests where only one action was stated. + +Pick 2–4 rows from the (Observation, Suggest, Calls) table below most relevant to the response, then call your host's widget with ITS schema (per the schema rules above — wrong schema fails silently): +- \`ask_user_input_v0\`: \`{questions:[{question,type:"single_select",options:["",""]}]}\` +- \`AskUserQuestion\`: \`{questions:[{question,header:"Next step",multiSelect:false,options:[{label:"<≤5 words>",description:""}]}]}\` + +User picks → call the matching \`Calls\` tool. Constraints: 2–4 mutually-exclusive options, AskUserQuestion labels ≤5 words (full text in \`description\`), max 3 questions. Table stays internal; never recite it. + +--- + + + +Pick the 2-3 options that match what actually happened — never all seven: + +| Observation | Suggest | Calls | +|---|---|---| +| Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) | +| Free run delivered on-profile leads | "Qualify these N against your criteria (paid — \`dry_run\` first)" | leadbay_qualify_leads(prior_deliveries: {job_id}) | +| Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach | +| Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) | +| Stopped at cost cap (\`stop_reason: max_cost\`) | "Raise the cap to X and get the remaining N" — X in the account's currency per the funnel-line rule, never a hard-coded \`$\` | leadbay_find_new_leads, NEW request_id (same-id only dedupes onto a LIVE job) + higher max_cost + \`count\` = the SHORTFALL (\`items_requested\` − delivered), not the original + \`exclude_lead_ids\` = the examined-but-REJECTED ids (novelty covers delivered; these are what it misses — without them the rerun re-buys the same losers) | +| Stopped on org quota (\`stop_reason: quota\`) | "Wait for the reset, or top up" — never a re-run: it cannot clear an org quota and burns a submit slot to stop in the same place | leadbay_account_status, then leadbay_create_topup_link | +| User wants these tracked in Leadbay | "Add the keepers to a campaign" | leadbay_create_campaign / leadbay_add_leads_to_campaign | +`; +// endregion: leadbay_find_new_leads + // region: leadbay_followups_map export const leadbay_followups_map: string = `## WHEN TO USE @@ -2079,6 +2333,204 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible `; // endregion: leadbay_launch_bulk_enrichment +// region: leadbay_lead_job_status +export const leadbay_lead_job_status: string = `## WHEN TO USE + +Trigger phrases: "is the lead search done", "any results yet on that job", "check on the delivery". + +**Memory:** recall + capture via \`leadbay_agent_memory_*\` tools. + +Do NOT use for: "is the enrichment done" → \`leadbay_bulk_enrich_status\`; "is my import done" → \`leadbay_import_status\`; "is the top-N qualification done" → \`leadbay_qualify_status\`. + +Prefer when: a find_new_leads / qualify_leads result carried next_poll — pass its job_id; use wait_seconds ~60 when the user asked to wait for results. + +Examples that SHOULD invoke this tool: +- "Any leads yet from that search you started?" +- "Wait for the qualification job to finish and show me everything." + +Examples that should NOT invoke this tool (sound similar, route elsewhere): +- "Is the email enrichment finished?" +- "Is my CSV import done?" + +## RENDER (quick) + +Terminal job -> render the full delivery per the lead-delivery table + +honest funnel line. Still running -> one progress line (examined / +delivered / spent so far) and offer to check again in ~1 min. Never +render UUIDs or cursors. + +--- + +Cumulative snapshot of a lead-delivery job: state, funnel counters, every +item emitted so far (full lead payloads for delivered/degraded, honest +status_reason for skipped), spend + breakdown, and the \`explain\` block +(basis, seed strategy, scope notes). Items are immutable once emitted — +polling never re-reads live data, so numbers only ever grow. + +\`wait_seconds: 0\` (default) answers instantly; set ~60 to block-wait for +completion when the user asked for results "in this reply". \`since\` (from a +prior poll's \`next_since\`) pages only the new items. Jobs terminalize +server-side: past the 30-min wall clock a job reads \`completed_partial\` +(time budget), past 30 days \`expired\` (items no longer listed — re-read +billed leads via leadbay_qualify_leads \`prior_deliveries\`). A 404 means +unknown job or another org's job. + +--- + +## RENDERING — delivery table + honest funnel line + +Render delivered leads (\`leads[]\`, i.e. items with status \`delivered\` or +\`degraded\`) as a markdown table **in the order returned**. Exactly three +columns. Then ALWAYS close with the funnel line (below) — even, especially, +when nothing was delivered. + +**Column 1 — Company** + +- Line 1: 10-segment fit bar in inline-code backticks from \`lead.fit.score\` + (0-100): \`filled = round(score/10)\`, glyphs \`▰\` filled / \`▱\` empty. When + \`lead.fit.components.qualification.available\` is true AND \`ai_score > 0\`, + replace the LAST filled segment with \`❖\` (AI-confirmed cap). When + \`fit.available\` is false, render \`▱▱▱▱▱▱▱▱▱▱\` and say "unscored" in col 2. + Never print the numeric score. +- Insert \`
\`, then: linked company name (target \`company.website\`, bare + hostnames get \`https://\`; unlinked plain text when absent) + \` · \` + short + location (City, ST / City, Country) + \` · \` + employees as \`min–max\` (omit + when \`employees.known\` is false). + +**Column 2 — Why it fits** + +- One sentence ≤ 20 words. Priority: \`fit.reasoning\` → gist of + \`company.description\` → top \`fit.components.qualification.matched_tags\`. +- If the item status is \`degraded\` or a requested channel failed, append the + honest flag in italics, e.g. *(email could not be sourced)*. + +**Column 3 — Contact** + +- \`[Name](linkedin) · role\` (linked name mandatory when a LinkedIn URL + exists; plain name otherwise). Below it, the PURCHASED channels only: + \`✉ value\` / \`☎ value\` inline as plain text (they auto-linkify). +- Channel statuses: \`delivered\` → show value; \`already_owned\` → value + + *(already yours)*; \`masked\` → "on file — reveal via channels"; + \`not_requested\` → omit; \`failed_*\` → *(no verified email/phone)*. +- No contact on the item (\`contact\` null): render \`—\` (title_gate \`prefer\` + delivers such rows flagged; say so in col 2 only when contact_titles were + requested). + +**The funnel line (mandatory, after the table):** + +One short line narrating the delivery honestly, from \`funnel\` + \`cost\` + +\`explain.scope_notes\`: + +> Matched N · examined E · qualified Q · disqualified D → **delivered X of +> the Y asked** · stopped: · spent C.CC. + +**Money: divide, then symbol.** Every amount (\`cost.spent\`, +\`estimated_cost.max\`, quotes) is \`cost_cents\` — divide by 100, two decimals, +so \`165\` renders \`1.65\`, NEVER \`165.00\`. Symbol from the account region: US +\`$\`, France \`€\`, unknown → bare. Never hard-code \`$\`: it misstates a charge. + +"of the Y asked" needs \`summary.items_requested\`, which submits carry but a +later \`leadbay_lead_job_status\` snapshot does not. Without it write **delivered +X** and stop — never back-fill Y from \`matched\`/\`examined\` (they count +candidates), never guess it. + +Plain-word stop reasons: \`target_reached\` → omit (success), \`pool_exhausted\` → +"ran out of matching candidates", \`max_cost\` → "hit the cost cap", \`quota\` → +"hit an org quota", \`time_budget\` → "hit the 30-min time budget". + +**When \`delivered\` is 0**: NEVER say just "no results". Render no table; give +the funnel line plus the relevant \`explain.scope_notes\` (the backend's own +diagnosis), then propose the concrete fix (reshape the seed per the craft +rules, lower \`min_ai_score\`, raise \`max_cost\`, drop a filter) as NEXT STEPS. + +**Weak batch**: when the BEST delivered \`fit.score\` is under 30, don't present +the table as an answer — open with "weak matches only", show at most the top 3, +propose reshaping the seed/filters first. The count was filled with +barely-better-than-random candidates. + +**Sanity-check every row**: (a) geo — \`city\`/\`region\` must sit inside any +requested fence; drop and call out leaks (same-named cities slip through). +(b) When \`explain.seed_strategy\` is \`text_match_exemplars\` (the standard FR +path), fit is calibrated for lead-to-lead distances, not exemplar centroids — +treat high scores skeptically and verify each row's \`description\`. + +**Skipped items** (\`skipped[]\`, qualify jobs mostly): render a compact second +table \`Ref → Outcome\` translating \`status_reason\` to plain words: +\`not_in_universe\` → "not in the Leadbay universe (import it first)", +\`low_confidence_identity\` → "couldn't safely match — check \`resolution.alternatives\`", +\`no_matching_contact\` → "no contact with the requested title", +\`disqualified\` → "evaluated: does not fit" (evidence is in the item when owned), +\`enrichment_failed\` → "channel could not be sourced (not billed)". + +**\`items_truncated\`**: rows are a PREFIX, not the batch. Say so, and offer +\`leadbay_lead_job_status(job_id, since: next_since)\` for the rest. + +**Hide from the user:** UUIDs (keep for tool calls, never render), cursors, +\`explain.model\`/\`intelligence_snapshot\`, raw \`distance\`/\`calibration\`, +\`seq\`/\`from_cache\`, empty arrays. + +## Linking a contact's name + +**MANDATORY: every contact name in your output — table cells, prose, headers, "Reach " callouts — MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception — the search URL below is always constructable from name + company.** + +URL priority (first applicable wins): + +1. **Real profile** — \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it). +2. **Constructed people-search** — \`https://www.linkedin.com/search/results/people/?keywords=++\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` °\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`°\` when a real \`linkedin_page\` was used. + +Never link a person's name to the company's LinkedIn page (and vice versa) — the two surfaces are different and conflating them quietly degrades the workflow. + + + +**Delivered ≠ endorsed.** This tool DELIVERS org-owned companies that FAILED +qualification, carrying their negative evidence — so a delivered item is not +automatically a prospect. An item whose \`status_reason\` is \`disqualified\`, or +whose \`fit.components.qualification\` is available with a negative \`ai_score\`, +must NOT go in the fit table: its firmographic score can still be high, and a +full bar beside "why it fits" reads as a recommendation to call an account the +evaluation just rejected. + +Give those their own short section after the fit table, titled +**Evaluated — does not fit**: linked company, then the verdict in plain +words from the +qualification evidence (failed question verdicts, missed tags, IBP reasoning). +That is the deliverable — "here's why to skip this account" — not a defect to +hide. + + +--- + +## NEXT STEPS — after a job status poll + +**ALWAYS render NEXT STEPS via your host's next-step widget.** Use whichever is in your tool set — the NAME and SCHEMA differ: **\`ask_user_input_v0\`** (Claude chat / ChatGPT) takes plain-string options with \`type:"single_select"\`; **\`AskUserQuestion\`** (Claude cowork / Claude Code) takes object options \`{label, description}\` plus a required short \`header\` (≤12 chars) and \`multiSelect\`, NO \`type\` field, and never add an "Other" option (the host adds it). Match the schema to the tool you actually have — the wrong schema fails silently and you fall back to prose. Prose bullets are the fallback ONLY when NEITHER widget exists. Any turn that would end with a choice must be the widget — the widget IS the question. + +**If the tool result carries a \`next_steps\` object, that is the source of truth — use it directly.** Each option has a short \`.label\` (≤5 words) and a full \`.description\`. Map \`next_steps.options[]\` into your host widget VERBATIM and in order: for \`AskUserQuestion\` (cowork / Claude Code) pass each as \`{label, description}\`; for \`ask_user_input_v0\` (Claude chat / ChatGPT, string options only) pass each option's \`.description\` as the string (it's the full sentence). Do NOT reword, reorder, drop, or prose-ify them — they're built deterministically by the server so the offer (incl. the artifact option at position 0) fires every time. Fall back to the table below only when there is NO \`next_steps\` field. + +**One exception — skip the widget** when the user's original message contained a complete sequential instruction chain ("show me X and then do Y") AND all stated steps have been completed. In that case, end with STOP directly — the user stated their full plan and does not need a "what next?" prompt. +- Skip example: "Show me today's leads and then research the top one for me." → after research completes, emit STOP without the widget. +- Do NOT skip for: plain requests ("show me today's leads", "run my check-in"), recurring-language requests ("I do this every day"), or requests where only one action was stated. + +Pick 2–4 rows from the (Observation, Suggest, Calls) table below most relevant to the response, then call your host's widget with ITS schema (per the schema rules above — wrong schema fails silently): +- \`ask_user_input_v0\`: \`{questions:[{question,type:"single_select",options:["",""]}]}\` +- \`AskUserQuestion\`: \`{questions:[{question,header:"Next step",multiSelect:false,options:[{label:"<≤5 words>",description:""}]}]}\` + +User picks → call the matching \`Calls\` tool. Constraints: 2–4 mutually-exclusive options, AskUserQuestion labels ≤5 words (full text in \`description\`), max 3 questions. Table stays internal; never recite it. + +--- + + + +Pick the ONE row matching the job's state and offer at most two options — this +is a status tool, keep it terse: + +| Observation | Suggest | Calls | +|---|---|---| +| Still running | "Keep waiting (~1 min) or leave it — results are kept 30 days" | leadbay_lead_job_status(job_id, wait_seconds: 60) | +| Terminal (completed / partial / failed) | Render the delivery per the RENDERING block, then offer the matching find_new_leads / qualify_leads NEXT STEPS | — | +| \`expired\` (past the 30-day window) | "Re-read the billed leads from your delivery ledger" — there is nothing left to render: the job terminalized and its items are no longer listed, so do NOT present an empty delivery as a result | leadbay_qualify_leads(prior_deliveries: {job_id}) | +`; +// endregion: leadbay_lead_job_status + // region: leadbay_like_lead export const leadbay_like_lead: string = `## WHEN TO USE @@ -2966,7 +3418,7 @@ Trigger phrases: "show me leads", "show me new leads", "show me today's leads", **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools. -Do NOT use for: "leads I should follow up with" → \`leadbay_pull_followups\`; "I'm going to " → \`leadbay_tour_plan\`; "I'm in next week — who's worth meeting" → \`leadbay_tour_plan\`; "who should I meet in " → \`leadbay_tour_plan\`; "visiting — who's worth meeting / seeing" → \`leadbay_tour_plan\`; "leads I should reach out to" → \`leadbay_pull_followups\`; "leads to get back to" → \`leadbay_pull_followups\`; "leads to contact today" → \`leadbay_pull_followups\`; "should I contact" → \`leadbay_pull_followups\`; "reconnect with" → \`leadbay_pull_followups\`; "re-engage" → \`leadbay_pull_followups\`. +Do NOT use for: "find me N companies that " → \`leadbay_find_new_leads\` (only if listed); "new prospects like with their emails" → \`leadbay_find_new_leads\` (only if listed); "leads I should follow up with" → \`leadbay_pull_followups\`; "I'm going to " → \`leadbay_tour_plan\`; "I'm in next week — who's worth meeting" → \`leadbay_tour_plan\`; "who should I meet in " → \`leadbay_tour_plan\`; "visiting — who's worth meeting / seeing" → \`leadbay_tour_plan\`; "leads I should reach out to" → \`leadbay_pull_followups\`; "leads to get back to" → \`leadbay_pull_followups\`; "leads to contact today" → \`leadbay_pull_followups\`; "should I contact" → \`leadbay_pull_followups\`; "reconnect with" → \`leadbay_pull_followups\`; "re-engage" → \`leadbay_pull_followups\`. Prefer when: fresh Discover leads; if a lens is named, pass \`lensId\` and pin it @@ -2975,6 +3427,7 @@ Examples that SHOULD invoke this tool: - "Pull my best new prospects." Examples that should NOT invoke this tool (sound similar, route elsewhere): +- "Find me 10 gyms around Dallas that would buy our flooring." - "Which leads should I follow up with this week?" - "I'm flying to Berlin Thursday — who should I meet?" - "I'm in San Francisco next Tuesday — who's worth meeting?" @@ -3137,6 +3590,236 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible `; // endregion: leadbay_qualify_lead +// region: leadbay_qualify_leads +export const leadbay_qualify_leads: string = `## WHEN TO USE + +Trigger phrases: "qualify these companies", "vet this list", "which of these fit our ICP", "score these websites / accounts", "get me the right contact at these companies", "re-qualify what you delivered last week". + +**Memory:** recall + capture via \`leadbay_agent_memory_*\` tools. + +Do NOT use for: "find me new leads / companies that " → \`leadbay_find_new_leads\`; "qualify the top N of my lens batch" → \`leadbay_bulk_qualify_leads\`; "import this CSV file" → \`leadbay_import_leads\`; "tell me about in depth" → \`leadbay_research_lead_by_name_fuzzy\`; "add emails to the contacts I selected" → \`leadbay_enrich_titles\`. + +Prefer when: the user points at SPECIFIC companies (ids, websites, names, a pasted list, "what you found yesterday") and wants fit verdicts and/or the right person to talk to. + +Examples that SHOULD invoke this tool: +- "Here are 60 restaurant websites from my Austin sweep — which fit, and who's the owner?" +- "Re-qualify last week's delivery and get phone numbers for the good ones." +- "Vet these 12 accounts from my spreadsheet against our criteria." + +Examples that should NOT invoke this tool (sound similar, route elsewhere): +- "Find me 10 new gyms in Texas." +- "Qualify the top 10 leads in my batch." +- "I have a CSV of 400 attendees to import." + +## RENDER (quick) + +3-col table for delivered items (fit bar + company / why-fits ≤20 words / +contact + channels) in returned order, then a compact Ref → Outcome table +for skipped refs (not_in_universe, low_confidence_identity, ... in plain +words), then the honest funnel + cost line. Full algorithm below. + +--- + +Submit a qualify batch over companies the org already has (or that exist in +the Leadbay universe): each ref is resolved to a known company, freshly +researched + AI-qualified against the org's questions / tags / ideal buyer +profile (frozen at submit), matched to the requested contact titles, and — +when asked — enriched with verified channels. Answers arrive per-item from a +job; this tool polls up to \`wait_seconds\` (default 45) and hands off to +\`leadbay_lead_job_status\` when the batch needs longer. + +**Refs are flexible; outcomes are per-item.** \`lead_refs\` accepts any mix of +\`lead_id\`, \`website\`, \`name\`(+\`location\`), or a stable \`contact_id\` from a +prior result (enrichment then targets exactly that person, never a re-match). +\`prior_deliveries\` expands past MCP deliveries into refs — billed leads stay +re-readable this way even after the 30-day result window. Duplicates collapse. +A ref that can't be served comes back \`skipped\` with an honest +\`status_reason\` (\`not_in_universe\`, \`low_confidence_identity\` with the +\`resolution.alternatives\` to choose from, \`no_matching_contact\`, ...) — that +is an ANSWER about the ref, not an error, and it costs nothing. + +**Disqualified ≠ dropped.** Companies the org owns that fail qualification +are DELIVERED with their negative evidence (question verdicts, tag misses, +IBP reasoning) — "here's why to skip this account" is a deliverable. + +**Cost — never spend silently.** Resolution and identity are free. +\`qualify: true\` (the default) costs ~94 cost_cents per lead needing FRESH +research+scoring — but repeat calls reuse every fresh cached stage +(\`from_cache\` flags on the items) and converge to near-zero cost. \`channels\` +purchase verified email (25c) / phone (250c) on success only; +\`already_owned\` values cost nothing. + +The gate is enforced in code, not just here: a PAID call (\`qualify\` left at +its default or set true, and/or any \`channels\`) is WITHHELD unless it carries +\`confirm: true\`. Without it the tool submits nothing and returns +\`mode: "needs_confirmation"\` with a real backend quote — show that quote to +the user, get the go-ahead (an explicit "spend / get their emails" in their +message counts), then re-call with \`confirm: true\`. \`confirm: false\` is a +veto: nothing is submitted and no quote round-trip is made. A fully FREE +call (\`qualify: false\`, no \`channels\`) needs no \`confirm\` and passes straight +through. Set \`request_id\` and reuse it on retries of the same batch. + +**Limits**: 500 refs/job, 3 active jobs/org, 10 submits/hour (429 + +Retry-After beyond — wait, don't hammer), 30-min job wall clock. + +--- + +## RENDERING — delivery table + honest funnel line + +Render delivered leads (\`leads[]\`, i.e. items with status \`delivered\` or +\`degraded\`) as a markdown table **in the order returned**. Exactly three +columns. Then ALWAYS close with the funnel line (below) — even, especially, +when nothing was delivered. + +**Column 1 — Company** + +- Line 1: 10-segment fit bar in inline-code backticks from \`lead.fit.score\` + (0-100): \`filled = round(score/10)\`, glyphs \`▰\` filled / \`▱\` empty. When + \`lead.fit.components.qualification.available\` is true AND \`ai_score > 0\`, + replace the LAST filled segment with \`❖\` (AI-confirmed cap). When + \`fit.available\` is false, render \`▱▱▱▱▱▱▱▱▱▱\` and say "unscored" in col 2. + Never print the numeric score. +- Insert \`
\`, then: linked company name (target \`company.website\`, bare + hostnames get \`https://\`; unlinked plain text when absent) + \` · \` + short + location (City, ST / City, Country) + \` · \` + employees as \`min–max\` (omit + when \`employees.known\` is false). + +**Column 2 — Why it fits** + +- One sentence ≤ 20 words. Priority: \`fit.reasoning\` → gist of + \`company.description\` → top \`fit.components.qualification.matched_tags\`. +- If the item status is \`degraded\` or a requested channel failed, append the + honest flag in italics, e.g. *(email could not be sourced)*. + +**Column 3 — Contact** + +- \`[Name](linkedin) · role\` (linked name mandatory when a LinkedIn URL + exists; plain name otherwise). Below it, the PURCHASED channels only: + \`✉ value\` / \`☎ value\` inline as plain text (they auto-linkify). +- Channel statuses: \`delivered\` → show value; \`already_owned\` → value + + *(already yours)*; \`masked\` → "on file — reveal via channels"; + \`not_requested\` → omit; \`failed_*\` → *(no verified email/phone)*. +- No contact on the item (\`contact\` null): render \`—\` (title_gate \`prefer\` + delivers such rows flagged; say so in col 2 only when contact_titles were + requested). + +**The funnel line (mandatory, after the table):** + +One short line narrating the delivery honestly, from \`funnel\` + \`cost\` + +\`explain.scope_notes\`: + +> Matched N · examined E · qualified Q · disqualified D → **delivered X of +> the Y asked** · stopped: · spent C.CC. + +**Money: divide, then symbol.** Every amount (\`cost.spent\`, +\`estimated_cost.max\`, quotes) is \`cost_cents\` — divide by 100, two decimals, +so \`165\` renders \`1.65\`, NEVER \`165.00\`. Symbol from the account region: US +\`$\`, France \`€\`, unknown → bare. Never hard-code \`$\`: it misstates a charge. + +"of the Y asked" needs \`summary.items_requested\`, which submits carry but a +later \`leadbay_lead_job_status\` snapshot does not. Without it write **delivered +X** and stop — never back-fill Y from \`matched\`/\`examined\` (they count +candidates), never guess it. + +Plain-word stop reasons: \`target_reached\` → omit (success), \`pool_exhausted\` → +"ran out of matching candidates", \`max_cost\` → "hit the cost cap", \`quota\` → +"hit an org quota", \`time_budget\` → "hit the 30-min time budget". + +**When \`delivered\` is 0**: NEVER say just "no results". Render no table; give +the funnel line plus the relevant \`explain.scope_notes\` (the backend's own +diagnosis), then propose the concrete fix (reshape the seed per the craft +rules, lower \`min_ai_score\`, raise \`max_cost\`, drop a filter) as NEXT STEPS. + +**Weak batch**: when the BEST delivered \`fit.score\` is under 30, don't present +the table as an answer — open with "weak matches only", show at most the top 3, +propose reshaping the seed/filters first. The count was filled with +barely-better-than-random candidates. + +**Sanity-check every row**: (a) geo — \`city\`/\`region\` must sit inside any +requested fence; drop and call out leaks (same-named cities slip through). +(b) When \`explain.seed_strategy\` is \`text_match_exemplars\` (the standard FR +path), fit is calibrated for lead-to-lead distances, not exemplar centroids — +treat high scores skeptically and verify each row's \`description\`. + +**Skipped items** (\`skipped[]\`, qualify jobs mostly): render a compact second +table \`Ref → Outcome\` translating \`status_reason\` to plain words: +\`not_in_universe\` → "not in the Leadbay universe (import it first)", +\`low_confidence_identity\` → "couldn't safely match — check \`resolution.alternatives\`", +\`no_matching_contact\` → "no contact with the requested title", +\`disqualified\` → "evaluated: does not fit" (evidence is in the item when owned), +\`enrichment_failed\` → "channel could not be sourced (not billed)". + +**\`items_truncated\`**: rows are a PREFIX, not the batch. Say so, and offer +\`leadbay_lead_job_status(job_id, since: next_since)\` for the rest. + +**Hide from the user:** UUIDs (keep for tool calls, never render), cursors, +\`explain.model\`/\`intelligence_snapshot\`, raw \`distance\`/\`calibration\`, +\`seq\`/\`from_cache\`, empty arrays. + +## Linking a contact's name + +**MANDATORY: every contact name in your output — table cells, prose, headers, "Reach " callouts — MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception — the search URL below is always constructable from name + company.** + +URL priority (first applicable wins): + +1. **Real profile** — \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it). +2. **Constructed people-search** — \`https://www.linkedin.com/search/results/people/?keywords=++\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` °\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`°\` when a real \`linkedin_page\` was used. + +Never link a person's name to the company's LinkedIn page (and vice versa) — the two surfaces are different and conflating them quietly degrades the workflow. + + + +**Delivered ≠ endorsed.** This tool DELIVERS org-owned companies that FAILED +qualification, carrying their negative evidence — so a delivered item is not +automatically a prospect. An item whose \`status_reason\` is \`disqualified\`, or +whose \`fit.components.qualification\` is available with a negative \`ai_score\`, +must NOT go in the fit table: its firmographic score can still be high, and a +full bar beside "why it fits" reads as a recommendation to call an account the +evaluation just rejected. + +Give those their own short section after the fit table, titled +**Evaluated — does not fit**: linked company, then the verdict in plain +words from the +qualification evidence (failed question verdicts, missed tags, IBP reasoning). +That is the deliverable — "here's why to skip this account" — not a defect to +hide. + + +--- + +## NEXT STEPS — after a qualify_leads delivery + +**ALWAYS render NEXT STEPS via your host's next-step widget.** Use whichever is in your tool set — the NAME and SCHEMA differ: **\`ask_user_input_v0\`** (Claude chat / ChatGPT) takes plain-string options with \`type:"single_select"\`; **\`AskUserQuestion\`** (Claude cowork / Claude Code) takes object options \`{label, description}\` plus a required short \`header\` (≤12 chars) and \`multiSelect\`, NO \`type\` field, and never add an "Other" option (the host adds it). Match the schema to the tool you actually have — the wrong schema fails silently and you fall back to prose. Prose bullets are the fallback ONLY when NEITHER widget exists. Any turn that would end with a choice must be the widget — the widget IS the question. + +**If the tool result carries a \`next_steps\` object, that is the source of truth — use it directly.** Each option has a short \`.label\` (≤5 words) and a full \`.description\`. Map \`next_steps.options[]\` into your host widget VERBATIM and in order: for \`AskUserQuestion\` (cowork / Claude Code) pass each as \`{label, description}\`; for \`ask_user_input_v0\` (Claude chat / ChatGPT, string options only) pass each option's \`.description\` as the string (it's the full sentence). Do NOT reword, reorder, drop, or prose-ify them — they're built deterministically by the server so the offer (incl. the artifact option at position 0) fires every time. Fall back to the table below only when there is NO \`next_steps\` field. + +**One exception — skip the widget** when the user's original message contained a complete sequential instruction chain ("show me X and then do Y") AND all stated steps have been completed. In that case, end with STOP directly — the user stated their full plan and does not need a "what next?" prompt. +- Skip example: "Show me today's leads and then research the top one for me." → after research completes, emit STOP without the widget. +- Do NOT skip for: plain requests ("show me today's leads", "run my check-in"), recurring-language requests ("I do this every day"), or requests where only one action was stated. + +Pick 2–4 rows from the (Observation, Suggest, Calls) table below most relevant to the response, then call your host's widget with ITS schema (per the schema rules above — wrong schema fails silently): +- \`ask_user_input_v0\`: \`{questions:[{question,type:"single_select",options:["",""]}]}\` +- \`AskUserQuestion\`: \`{questions:[{question,header:"Next step",multiSelect:false,options:[{label:"<≤5 words>",description:""}]}]}\` + +User picks → call the matching \`Calls\` tool. Constraints: 2–4 mutually-exclusive options, AskUserQuestion labels ≤5 words (full text in \`description\`), max 3 questions. Table stays internal; never recite it. + +--- + + + +Pick the 2-3 options that match what actually happened: + +| Observation | Suggest | Calls | +|---|---|---| +| Job still running | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) | +| Fit leads with contacts delivered | "Draft outreach for the qualified ones" | leadbay_prepare_outreach | +| Items skipped \`not_in_universe\` | "Import those companies first, then re-qualify" | leadbay_import_leads → leadbay_qualify_leads | +| Items skipped \`low_confidence_identity\` | "Pick the right match" (show \`resolution.alternatives\`) | leadbay_qualify_leads with the chosen lead_id | +| Contacts delivered without channels | "Purchase verified emails/phones for the keepers (state cost first)" | leadbay_qualify_leads(lead_refs with contact_id, channels) | +| Disqualified with evidence | "Review why — adjust qualification questions if the criteria are off" | leadbay_get_qualification_questions | +`; +// endregion: leadbay_qualify_leads + // region: leadbay_qualify_status export const leadbay_qualify_status: string = `Retrieve the current state of an import_and_qualify (or bulk_qualify_leads) launch by \`qualify_id\`. Returns the same \`qualified[]\` / \`still_running[]\` shape as the original composite, refreshed against the backend at call time. The handle is persisted to \`~/.leadbay/bulks.json\` with a 30-day TTL and survives MCP restart. @@ -4622,6 +5305,7 @@ export const TOOL_DESCRIPTIONS = { leadbay_enrich_contacts, leadbay_enrich_titles, leadbay_extend_lens, + leadbay_find_new_leads, leadbay_followups_map, leadbay_get_clarification, leadbay_get_contacts, @@ -4645,6 +5329,7 @@ export const TOOL_DESCRIPTIONS = { leadbay_import_leads, leadbay_import_status, leadbay_launch_bulk_enrichment, + leadbay_lead_job_status, leadbay_like_lead, leadbay_list_campaigns, leadbay_list_lenses, @@ -4663,6 +5348,7 @@ export const TOOL_DESCRIPTIONS = { leadbay_pull_followups, leadbay_pull_leads, leadbay_qualify_lead, + leadbay_qualify_leads, leadbay_qualify_status, leadbay_recall_ordered_titles, leadbay_refine_prompt, diff --git a/packages/core/test/unit/client-401-retry-slot-accounting.test.ts b/packages/core/test/unit/client-401-retry-slot-accounting.test.ts new file mode 100644 index 00000000..31d7ca99 --- /dev/null +++ b/packages/core/test/unit/client-401-retry-slot-accounting.test.ts @@ -0,0 +1,96 @@ +/** + * The 401 retry is the only path that hands its semaphore slot back mid-request + * and then tries to take it again. That makes "does this call still hold a + * slot?" a variable rather than a constant, and it is the one place where an + * abortable re-acquisition can corrupt the counter: if re-acquisition rejects, + * the caller's `finally` must NOT release a slot it no longer owns. + * + * Drift here is permanent and one-directional — every occurrence costs the + * client another slot until it can serve nothing at all. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +let nextStatus = 200; +const pending: Array<() => void> = []; + +vi.mock("node:https", () => ({ + default: { + request: (_o: Record, cb?: (res: unknown) => void) => { + const status = nextStatus; + const req = new EventEmitter() as EventEmitter & { + write: () => void; + end: () => void; + destroy: () => void; + }; + req.write = () => {}; + req.destroy = () => {}; + req.end = () => { + const finish = () => { + const res = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + }; + res.statusCode = status; + res.headers = {}; + cb?.(res); + res.emit("data", Buffer.from("{}")); + res.emit("end"); + }; + // A 401 answers at once so the retry path is entered; everything else + // parks, so the test controls when slots free up. + if (status === 401) setImmediate(finish); + else pending.push(finish); + }; + return req; + }, + }, +})); + +import { LeadbayClient } from "../../src/client.js"; + +const MAX_CONCURRENT = 5; +const newClient = () => new LeadbayClient("https://api-us.leadbay.app", "u.tok", "us"); +const settled = () => new Promise((r) => setImmediate(r)); + +beforeEach(() => { + nextStatus = 200; + pending.length = 0; +}); + +describe("401 retry — slot accounting survives an abort during the backoff", () => { + it("does not release a slot it failed to re-acquire", async () => { + const client = newClient(); + + // A GET that 401s: it releases its slot and enters the 250ms backoff. + nextStatus = 401; + const ac = new AbortController(); + const retrying = client + .request("GET", "/users/me", undefined, { signal: ac.signal }) + .catch(() => "rejected"); + await settled(); + + // While it is backing off, unrelated traffic takes every slot, so its + // re-acquisition will have to queue. + nextStatus = 200; + const stalled = Array.from({ length: MAX_CONCURRENT }, () => + client.request("GET", "/stalled").catch(() => {}) + ); + await settled(); + expect(client._semaphoreState.active).toBe(MAX_CONCURRENT); + + // Cancel while it is queued for its slot back. + ac.abort(); + await retrying; + + // THE ASSERTION: the five stalled requests still hold exactly five slots. + // Releasing unconditionally in the caller's `finally` would decrement one + // of THEIR slots — the counter drifts to 4 and one slot is gone for good. + expect(client._semaphoreState.active).toBe(MAX_CONCURRENT); + expect(client._semaphoreState.queued).toBe(0); + + pending.splice(0).forEach((f) => f()); + await Promise.all(stalled); + expect(client._semaphoreState.active).toBe(0); + }); +}); diff --git a/packages/core/test/unit/client-queue-deadline.test.ts b/packages/core/test/unit/client-queue-deadline.test.ts new file mode 100644 index 00000000..ebc9e6c3 --- /dev/null +++ b/packages/core/test/unit/client-queue-deadline.test.ts @@ -0,0 +1,153 @@ +/** + * `timeoutMs` is the caller's TOTAL budget for a call, not its socket budget. + * + * It used to start only when httpsRequest ran — i.e. after the semaphore was + * acquired. Five slow or stalled unrelated requests therefore let a bounded + * call sit in the queue without limit, so even `wait_seconds: 1` could exceed + * its contract by minutes while every individual request looked well-behaved. + * Time spent waiting for a slot is time the caller waited. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +const seen: Array> = []; +const pending: Array<() => void> = []; + +vi.mock("node:https", () => ({ + default: { + request: (options: Record, cb?: (res: unknown) => void) => { + seen.push(options); + const req = new EventEmitter() as EventEmitter & { + write: () => void; + end: () => void; + destroy: () => void; + }; + req.write = () => {}; + req.destroy = () => {}; + req.end = () => { + pending.push(() => { + const res = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + }; + res.statusCode = 200; + res.headers = {}; + cb?.(res); + res.emit("data", Buffer.from("{}")); + res.emit("end"); + }); + }; + return req; + }, + }, +})); + +import { LeadbayClient } from "../../src/client.js"; + +const MAX_CONCURRENT = 5; +const newClient = () => new LeadbayClient("https://api-us.leadbay.app", "u.tok", "us"); +const settled = () => new Promise((r) => setImmediate(r)); +const flush = () => pending.splice(0).forEach((f) => f()); +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +beforeEach(() => { + seen.length = 0; + pending.length = 0; +}); + +describe("the request deadline covers the queue wait", () => { + it("expires a queued call instead of stranding it behind stalled traffic", async () => { + const client = newClient(); + const stalled = Array.from({ length: MAX_CONCURRENT }, () => + client.request("GET", "/stalled").catch(() => {}) + ); + await settled(); + expect(client._semaphoreState.active).toBe(MAX_CONCURRENT); + seen.length = 0; + + // Bounded call, no slot available, and nothing will free one. + const bounded = client.request("GET", "/mcp/jobs/j1", undefined, { timeoutMs: 60 }); + + await expect(bounded).rejects.toMatchObject({ code: "TIMEOUT" }); + // It never reached the wire — the deadline was enforced while queued. + expect(seen).toHaveLength(0); + // And it left the queue clean rather than lingering as a dead waiter. + expect(client._semaphoreState.queued).toBe(0); + expect(client._semaphoreState.active).toBe(MAX_CONCURRENT); + + flush(); + await Promise.all(stalled); + expect(client._semaphoreState.active).toBe(0); + }); + + it("refuses immediately when the budget is already spent", async () => { + const client = newClient(); + await expect( + client.request("GET", "/mcp/jobs/j1", undefined, { timeoutMs: 0 }) + ).rejects.toMatchObject({ code: "TIMEOUT" }); + expect(seen).toHaveLength(0); + expect(client._semaphoreState.active).toBe(0); + }); + + it("charges queue time against a TOTAL budget rather than restarting it", async () => { + const client = newClient(); + const stalled = Array.from({ length: MAX_CONCURRENT }, () => + client.request("GET", "/stalled").catch(() => {}) + ); + await settled(); + seen.length = 0; + + const startedAt = Date.now(); + // 300ms TOTAL. ~200ms of it will be spent queued; the socket must inherit + // only what is LEFT. (`timeoutMs` is the other knob — a per-attempt bound — + // and is asserted separately below.) + const bounded = client + .request("GET", "/mcp/jobs/j1", undefined, { totalTimeoutMs: 300 }) + .catch((e) => e); + + await wait(200); + // Free every slot. `bounded` acquires and dispatches — and is never + // answered, so its socket deadline decides when it fails. + flush(); + const err = await bounded; + const elapsed = Date.now() - startedAt; + + expect(err).toMatchObject({ code: "TIMEOUT" }); + // A restarted clock would give it a fresh 300ms after acquiring, landing + // near 500ms. Spending the remaining ~100ms lands near 300ms. + expect(elapsed).toBeLessThan(450); + await Promise.all(stalled); + }); + + it("bounds the queue wait with the per-attempt knob too", async () => { + // `timeoutMs` deliberately gives each ATTEMPT its own window (the hosted + // auth probe needs that — its 401 backoff outlasts its probe budget). It + // must still bound the QUEUE, or a bounded call is unbounded before it + // ever becomes an attempt. + const client = newClient(); + const stalled = Array.from({ length: MAX_CONCURRENT }, () => + client.request("GET", "/stalled").catch(() => {}) + ); + await settled(); + seen.length = 0; + + await expect( + client.request("GET", "/mcp/jobs/j1", undefined, { timeoutMs: 60 }) + ).rejects.toMatchObject({ code: "TIMEOUT" }); + expect(seen).toHaveLength(0); + expect(client._semaphoreState.queued).toBe(0); + + flush(); + await Promise.all(stalled); + }); + + it("leaves unbounded calls unbounded", async () => { + const client = newClient(); + const p = client.request("GET", "/mcp/jobs/j1"); + await settled(); + expect(seen).toHaveLength(1); + expect(seen[0].signal).toBeUndefined(); + flush(); + await p; + }); +}); diff --git a/packages/core/test/unit/client-semaphore-abort.test.ts b/packages/core/test/unit/client-semaphore-abort.test.ts new file mode 100644 index 00000000..04fcb555 --- /dev/null +++ b/packages/core/test/unit/client-semaphore-abort.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +// Own node:https double: this suite needs requests that never answer, so it can +// occupy every concurrency slot and inspect what a queued caller does. The +// shared harness always responds on setImmediate, which is precisely the case +// that hides this bug. +const open: Array<() => void> = []; + +vi.mock("node:https", () => ({ + default: { + request: (_options: Record, cb?: (res: unknown) => void) => { + const req = new EventEmitter() as EventEmitter & { + write: () => void; + end: () => void; + destroy: () => void; + }; + req.write = () => {}; + req.destroy = () => {}; + req.end = () => { + // Park it. `open` holds the completion trigger so the test decides when + // (or whether) a slot is ever given back. + open.push(() => { + const res = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + }; + res.statusCode = 200; + res.headers = {}; + cb?.(res); + res.emit("data", Buffer.from("{}")); + res.emit("end"); + }); + }; + return req; + }, + }, +})); + +import { LeadbayClient } from "../../src/client.js"; + +const MAX_CONCURRENT = 5; +const newClient = () => new LeadbayClient("https://api-us.leadbay.app", "u.test-token", "us"); + +beforeEach(() => { + open.length = 0; +}); + +const settled = () => new Promise((r) => setImmediate(r)); + +describe("client — a cancelled call queued on the semaphore does not wait for unrelated traffic", () => { + it("rejects the queued waiter on abort instead of blocking until a slot frees", async () => { + const client = newClient(); + // Occupy every slot with requests that never answer. + const stalled = Array.from({ length: MAX_CONCURRENT }, () => + client.request("GET", "/stalled").catch(() => {}) + ); + await settled(); + expect(client._semaphoreState.active).toBe(MAX_CONCURRENT); + + const ac = new AbortController(); + const queued = client.request("GET", "/users/me", undefined, { signal: ac.signal }); + await settled(); + expect(client._semaphoreState.queued).toBe(1); + + ac.abort(); + + // The point of the test: this resolves while all five slots are STILL held. + await expect(queued).rejects.toMatchObject({ code: "REQUEST_CANCELLED" }); + expect(client._semaphoreState.active).toBe(MAX_CONCURRENT); + void stalled; + }); + + it("removes the cancelled waiter from the queue, so no slot is leaked", async () => { + const client = newClient(); + const stalled = Array.from({ length: MAX_CONCURRENT }, () => + client.request("GET", "/stalled").catch(() => {}) + ); + await settled(); + + const ac = new AbortController(); + const queued = client.request("GET", "/users/me", undefined, { signal: ac.signal }); + await settled(); + ac.abort(); + await expect(queued).rejects.toMatchObject({ code: "REQUEST_CANCELLED" }); + + // A tombstoned (rather than spliced) waiter would still be shifted here, + // taking the ++ and resolving nobody — one slot lost per cancellation. + expect(client._semaphoreState.queued).toBe(0); + + open.forEach((finish) => finish()); + await Promise.all(stalled); + expect(client._semaphoreState.active).toBe(0); + expect(client._semaphoreState.queued).toBe(0); + }); + + it("refuses an already-aborted signal without taking a slot at all", async () => { + const client = newClient(); + const ac = new AbortController(); + ac.abort(); + + await expect( + client.request("GET", "/users/me", undefined, { signal: ac.signal }) + ).rejects.toMatchObject({ code: "REQUEST_CANCELLED" }); + expect(client._semaphoreState.active).toBe(0); + }); +}); + +describe("client — the 401 retry gives its slot back safely", () => { + // The retry path is the only place a request hands its slot back mid-flight. + // That makes "does this call still hold a slot?" a variable, and getting it + // wrong drifts the counter permanently in one direction or the other. + it("does not surrender the slot for a call already cancelled", async () => { + const client = newClient(); + const ac = new AbortController(); + const p = client.request("GET", "/users/me", undefined, { signal: ac.signal }); + await settled(); + + ac.abort(); + open.forEach((finish) => finish()); + await p.catch(() => {}); + + // Whatever happened, the accounting must balance: no slot left held, none + // released twice into a negative count. + expect(client._semaphoreState.active).toBe(0); + expect(client._semaphoreState.queued).toBe(0); + }); + + it("leaves the counter balanced across a burst of cancellations", async () => { + const client = newClient(); + const stalled = Array.from({ length: MAX_CONCURRENT }, () => + client.request("GET", "/stalled").catch(() => {}) + ); + await settled(); + + const controllers = Array.from({ length: 4 }, () => new AbortController()); + const queued = controllers.map((ac) => + client.request("GET", "/users/me", undefined, { signal: ac.signal }).catch(() => {}) + ); + await settled(); + expect(client._semaphoreState.queued).toBe(4); + + controllers.forEach((ac) => ac.abort()); + await Promise.all(queued); + // A leaked slot per cancellation would show up here as a queue that never + // empties, and then as a client that can serve nothing. + expect(client._semaphoreState.queued).toBe(0); + expect(client._semaphoreState.active).toBe(MAX_CONCURRENT); + + open.forEach((finish) => finish()); + await Promise.all(stalled); + expect(client._semaphoreState.active).toBe(0); + }); +}); diff --git a/packages/core/test/unit/client-signal-wire.test.ts b/packages/core/test/unit/client-signal-wire.test.ts new file mode 100644 index 00000000..629bdda8 --- /dev/null +++ b/packages/core/test/unit/client-signal-wire.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +// Deliberately NOT the shared harness: its stub drops `options.signal` on the +// floor, so it cannot see this boundary. The merge that brought the hosted +// auth deadline (`timeoutMs`) alongside caller cancellation (`signal`) made +// them adjacent optional params of the same three functions — a swapped slot +// is exactly the kind of mistake that type-checks away but is worth pinning, +// because both are `undefined` on almost every call. +const captured: Array> = []; + +vi.mock("node:https", () => ({ + default: { + request: (options: Record, cb?: (res: unknown) => void) => { + captured.push(options); + const req = new EventEmitter() as EventEmitter & { + write: () => void; + end: () => void; + destroy: () => void; + }; + req.write = () => {}; + req.destroy = () => {}; + req.end = () => { + const res = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + }; + res.statusCode = 200; + res.headers = {}; + setImmediate(() => { + cb?.(res); + res.emit("data", Buffer.from(JSON.stringify({ ok: true }))); + res.emit("end"); + }); + }; + return req; + }, + }, +})); + +import { LeadbayClient } from "../../src/client.js"; + +const newClient = () => new LeadbayClient("https://api-us.leadbay.app", "u.test-token", "us"); + +beforeEach(() => { + captured.length = 0; +}); + +describe("client — AbortSignal reaches the https request options", () => { + it("forwards the caller's signal onto the request, not into the deadline slot", async () => { + const ac = new AbortController(); + await newClient().request("GET", "/users/me", undefined, { signal: ac.signal }); + + expect(captured).toHaveLength(1); + // The identity check is the point: if `signal` landed in the `timeoutMs` + // position it would never appear here at all. + expect(captured[0].signal).toBe(ac.signal); + }); + + it("omits signal when the caller passes none, without disturbing the request", async () => { + await newClient().request("GET", "/users/me"); + + expect(captured).toHaveLength(1); + expect(captured[0].signal).toBeUndefined(); + expect(captured[0].method).toBe("GET"); + }); + + it("carries the signal on write paths too", async () => { + const ac = new AbortController(); + await newClient().request("POST", "/leads/search", { q: "x" }, { signal: ac.signal }); + + expect(captured).toHaveLength(1); + expect(captured[0].signal).toBe(ac.signal); + expect(captured[0].method).toBe("POST"); + }); +}); diff --git a/packages/core/test/unit/client-total-budget-retry.test.ts b/packages/core/test/unit/client-total-budget-retry.test.ts new file mode 100644 index 00000000..492afcab --- /dev/null +++ b/packages/core/test/unit/client-total-budget-retry.test.ts @@ -0,0 +1,101 @@ +/** + * `timeoutMs` and `totalTimeoutMs` answer different questions, and the 401 + * retry is where the difference bites. + * + * timeoutMs — bounds ONE attempt. The hosted auth probe needs this: its + * 250ms 401-backoff outlasts a 200ms probe budget, so a + * retry charged against the first attempt's clock is deleted + * rather than bounded. + * totalTimeoutMs — bounds the WHOLE call, backoff and retry included. A job + * poll needs this: a transient 401 must not buy it a second + * full wait_seconds on top of the one it already spent. + * + * Before they were separated, one knob had to be both, and whichever behaviour + * it picked was wrong for the other caller. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +let status = 200; +const calls: number[] = []; + +vi.mock("node:https", () => ({ + default: { + request: (_o: Record, cb?: (res: unknown) => void) => { + calls.push(Date.now()); + const s = status; + const req = new EventEmitter() as EventEmitter & { + write: () => void; + end: () => void; + destroy: () => void; + }; + req.write = () => {}; + req.destroy = () => {}; + req.end = () => { + setImmediate(() => { + const res = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + }; + res.statusCode = s; + res.headers = {}; + cb?.(res); + res.emit("data", Buffer.from("{}")); + res.emit("end"); + }); + }; + return req; + }, + }, +})); + +import { LeadbayClient } from "../../src/client.js"; + +const newClient = () => new LeadbayClient("https://api-us.leadbay.app", "u.tok", "us"); + +beforeEach(() => { + status = 200; + calls.length = 0; +}); + +describe("401 retry and the two budget knobs", () => { + it("a per-attempt budget survives the 250ms backoff and retries", async () => { + status = 401; + // 200ms per attempt, backoff 250ms: the retry only happens if its window + // starts after the sleep. This is the auth-probe contract. + await newClient() + .request("GET", "/users/me", undefined, { timeoutMs: 200 }) + .catch(() => {}); + expect(calls).toHaveLength(2); + }); + + it("a TOTAL budget refuses to fund a second attempt it cannot afford", async () => { + status = 401; + const startedAt = Date.now(); + const err = await newClient() + .request("GET", "/users/me", undefined, { totalTimeoutMs: 200 }) + .catch((e) => e); + + expect(err).toMatchObject({ code: "TIMEOUT" }); + // The first attempt happened; the retry did not, because the 250ms backoff + // already exhausted the caller's whole 200ms. + expect(calls).toHaveLength(1); + // And it did not silently run on to a second full budget. + expect(Date.now() - startedAt).toBeLessThan(400); + }); + + it("honours both at once — whichever expires first wins", async () => { + status = 401; + await newClient() + .request("GET", "/users/me", undefined, { timeoutMs: 200, totalTimeoutMs: 5000 }) + .catch(() => {}); + // Generous total, per-attempt window fresh after the backoff → retry runs. + expect(calls).toHaveLength(2); + }); + + it("leaves a call with neither knob unbounded", async () => { + status = 401; + await newClient().request("GET", "/users/me").catch(() => {}); + expect(calls).toHaveLength(2); + }); +}); diff --git a/packages/core/test/unit/composite/country-guard-region-scope.test.ts b/packages/core/test/unit/composite/country-guard-region-scope.test.ts new file mode 100644 index 00000000..d7d762d7 --- /dev/null +++ b/packages/core/test/unit/composite/country-guard-region-scope.test.ts @@ -0,0 +1,71 @@ +/** + * The country-guard exemptions are per-universe, not process-wide. + * + * `Georgia` has to survive the guard on a US account — it is a state there, + * and a common one to fence on. It must NOT survive on a France account, + * where it can only mean the country and would hit the same arbitrary-locality + * fencing the guard exists to prevent. The French overseas regions are the + * exact mirror: legitimate fences on a France account, foreign countries on a + * US one. + * + * A single global exemption set gave each of them a free pass on the wrong + * side of that line. + */ + +import { describe, it, expect } from "vitest"; +import { rejectCountryLocations } from "../../../src/composite/_mcp-job-helpers.js"; + +function rejects(value: unknown, region?: string): boolean { + try { + rejectCountryLocations(value, region); + return false; + } catch (e) { + expect((e as { code?: string }).code).toBe("COUNTRY_LEVEL_LOCATION"); + return true; + } +} + +describe("rejectCountryLocations — region-scoped exemptions", () => { + it("Georgia is a state on US and a country on FR", () => { + expect(rejects(["Georgia"], "us")).toBe(false); + expect(rejects(["Georgia"], "fr")).toBe(true); + expect(rejects(["Géorgie"], "fr")).toBe(true); + }); + + it("the French overseas regions are fences on FR and countries on US", () => { + for (const v of ["Martinique", "Guadeloupe", "La Réunion", "Mayotte"]) { + expect(rejects([v], "fr"), `${v} on fr`).toBe(false); + expect(rejects([v], "us"), `${v} on us`).toBe(true); + } + }); + + it("an unknown region falls back to permissive — never a false rejection", () => { + // Without a region we cannot tell a state fence from a foreign country. + // Wrongly rejecting a correct search is the louder failure, so the union + // of exemptions applies. + expect(rejects(["Georgia"], undefined)).toBe(false); + expect(rejects(["Martinique"], "")).toBe(false); + expect(rejects(["Georgia"], "zz")).toBe(false); + }); + + it("region casing and padding do not change the verdict", () => { + expect(rejects(["Georgia"], " US ")).toBe(false); + expect(rejects(["Georgia"], "FR")).toBe(true); + }); + + it("real countries are still rejected in every region", () => { + for (const region of ["us", "fr", undefined]) { + for (const v of ["Canada", "Germany", "United Kingdom", "France"]) { + expect(rejects([v], region), `${v} on ${region}`).toBe(true); + } + } + }); + + it("ordinary cities and states survive in every region", () => { + for (const region of ["us", "fr", undefined]) { + for (const v of ["Austin", "Texas", "Lyon", "Nouvelle-Aquitaine"]) { + expect(rejects([v], region), `${v} on ${region}`).toBe(false); + } + } + }); +}); diff --git a/packages/core/test/unit/composite/country-location-guard.test.ts b/packages/core/test/unit/composite/country-location-guard.test.ts new file mode 100644 index 00000000..c4841796 --- /dev/null +++ b/packages/core/test/unit/composite/country-location-guard.test.ts @@ -0,0 +1,111 @@ +/** + * `rejectCountryLocations` — the country-level fence guard, both directions. + * + * The guard originally recognized only US and France aliases, so `Canada`, + * `United Kingdom`, `Germany` and every other country sailed through to + * `/mcp/search` and hit the very failure the guard exists to stop: the backend + * excludes countries from admin-area search, so the trigram resolver falls + * through to an arbitrary same-named town and fences the whole job to one + * village — silently. + * + * The fix builds the set from `Intl.DisplayNames` over every ISO 3166-1 code + * in English and French. That is comprehensive, and comprehensiveness is + * exactly what makes the SECOND half of this file load-bearing: a naive + * country list also swallows `Georgia` (a US state, and a common one to fence + * on) and every French overseas region, each of which carries its own ISO + * entry. Rejecting those would trade a silent wrong fence for a loud wrong + * rejection on a legitimate search. + */ + +import { describe, it, expect } from "vitest"; +import { rejectCountryLocations } from "../../../src/composite/_mcp-job-helpers.js"; + +/** The guard throws a plain error envelope, not an Error instance. */ +function rejects(value: unknown): boolean { + try { + rejectCountryLocations(value); + return false; + } catch (e) { + expect((e as { code?: string }).code).toBe("COUNTRY_LEVEL_LOCATION"); + return true; + } +} + +describe("rejectCountryLocations — countries are rejected", () => { + it("rejects the two originally-covered countries", () => { + for (const v of ["United States", "USA", "France", "la France"]) { + expect(rejects([v]), v).toBe(true); + } + }); + + it("rejects the countries the two-country allowlist let through", () => { + // The regression Codex caught on #168: these reached /mcp/search untouched. + for (const v of ["Canada", "United Kingdom", "Germany", "Spain", "Japan"]) { + expect(rejects([v]), v).toBe(true); + } + }); + + it("rejects French-language country names too", () => { + for (const v of ["Allemagne", "Royaume-Uni", "Espagne", "Belgique"]) { + expect(rejects([v]), v).toBe(true); + } + }); + + it("still folds spelling variants — accents, articles, punctuation", () => { + for (const v of [" the United Kingdom ", "l'Allemagne", "ESPAGNE"]) { + expect(rejects([v]), v).toBe(true); + } + }); + + it("still catches a bare string passed where a list was declared", () => { + expect(rejects("Germany")).toBe(true); + }); +}); + +describe("rejectCountryLocations — legitimate sub-national fences survive", () => { + it("does not reject Georgia, which is a US state before it is a country", () => { + for (const v of ["Georgia", "georgia", "Géorgie"]) { + expect(rejects([v]), v).toBe(false); + } + }); + + it("does not reject the French overseas regions", () => { + // Each carries its own ISO 3166-1 entry, so a comprehensive country list + // swallows all of them — while "leads in Martinique" is a normal ask. + for (const v of [ + "Guadeloupe", + "Martinique", + "La Réunion", + "Mayotte", + "Guyane française", + "Nouvelle-Calédonie", + ]) { + expect(rejects([v]), v).toBe(false); + } + }); + + it("does not reject ordinary cities, states and regions", () => { + for (const v of [ + "Austin", + "Texas", + "Lyon", + "Nouvelle-Aquitaine", + "Brooklyn", + ]) { + expect(rejects([v]), v).toBe(false); + } + }); + + it("lets a town be disambiguated by its state, as the hint tells the user", () => { + // "Lebanon" alone IS ambiguous and is rejected on purpose; qualifying it + // folds to a two-word key that no country name matches. + expect(rejects(["Lebanon"])).toBe(true); + expect(rejects(["Lebanon, Kentucky"])).toBe(false); + }); + + it("no-ops on absent locations", () => { + expect(rejects(undefined)).toBe(false); + expect(rejects(null)).toBe(false); + expect(rejects([])).toBe(false); + }); +}); diff --git a/packages/core/test/unit/composite/exclude-ids-malformed.test.ts b/packages/core/test/unit/composite/exclude-ids-malformed.test.ts new file mode 100644 index 00000000..c8c80be0 --- /dev/null +++ b/packages/core/test/unit/composite/exclude-ids-malformed.test.ts @@ -0,0 +1,103 @@ +/** + * Malformed exclusion ids are refused, not silently dropped. + * + * Once the submit body started sending `canonicalIdSet(exclude_lead_ids)` + * rather than the raw array, a non-string entry stopped reaching the backend + * at all: canonicalIdSet maps it to null and filters it. So a paid search ran + * WITHOUT an exclusion the caller asked for, and could re-deliver — and + * charge for — exactly the lead they were trying to skip. Blanks drop the + * same way. + * + * That silent narrowing is the regression the wire fix introduced; refusing + * the list keeps the caller's intent intact. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { findNewLeads } from "../../../src/composite/find-new-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); +const UUID = "7b3c1de2-5f40-4a9c-9d21-0c8ea4f61b55"; + +beforeEach(() => resetHttpMock()); + +async function refusal(exclude_lead_ids: unknown) { + mockHttp([]); + try { + await findNewLeads.execute(newClient(), { + example_lead: { description: "independent gym" }, + count: 5, + request_id: "excl-test", + exclude_lead_ids, + wait_seconds: 0, + } as any); + return null; + } catch (e) { + return e as { code?: string; message?: string; hint?: string }; + } +} + +describe("leadbay_find_new_leads — malformed exclude_lead_ids", () => { + it("refuses a numeric entry rather than dropping it", async () => { + const e = await refusal([123, UUID]); + expect(e!.code).toBe("INVALID_EXCLUDE_LEAD_ID"); + expect(e!.message).toMatch(/0 \(number\)/); + }); + + it("refuses null and blank entries", async () => { + expect((await refusal([null]))!.message).toMatch(/0 \(null\)/); + expect((await refusal([" "]))!.message).toMatch(/0 \(blank\)/); + }); + + it("says why silence would be worse", async () => { + // The hint has to carry the consequence: this is a PAID path and the + // dropped exclusion is the lead the caller is trying not to pay for. + const e = await refusal([123]); + expect(e!.hint).toMatch(/charge|pay/i); + }); + + it("nothing reaches the wire", async () => { + await refusal([123, UUID]); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("still accepts a clean list", async () => { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/search", + status: 200, + body: { job_id: "job-1", state: "queued", items: [] }, + }, + { + method: "GET", + path: /^\/1\.6\/mcp\/jobs\//, + status: 200, + body: { + job: { id: "job-1", state: "succeeded" }, + funnel: { delivered: 0, examined: 0 }, + items: [], + }, + }, + ]); + await findNewLeads.execute(newClient(), { + example_lead: { description: "independent gym" }, + count: 5, + request_id: "excl-ok", + exclude_lead_ids: [UUID], + wait_seconds: 0, + } as any); + const post = getHttpRequests().find((r) => r.method === "POST"); + expect(JSON.parse(post!.body ?? "{}").exclude_lead_ids).toEqual([UUID]); + }); +}); diff --git a/packages/core/test/unit/composite/exclude-lead-ids-cap.test.ts b/packages/core/test/unit/composite/exclude-lead-ids-cap.test.ts new file mode 100644 index 00000000..245ede84 --- /dev/null +++ b/packages/core/test/unit/composite/exclude-lead-ids-cap.test.ts @@ -0,0 +1,93 @@ +/** + * `exclude_lead_ids` is capped at 500, and the shortfall top-up is exactly + * where that bites. + * + * The workflow prompt used to say "pass every lead already SEEN" into the + * top-up's exclusion list. A paid run may examine up to `exploration_cap`'s + * ceiling — min(20n, 1000) — so on a wide run that instruction builds a list + * the backend refuses outright, killing the one call that exists to close a + * gap the user has already paid toward. + * + * Two halves to the fix, both pinned here: the tool refuses an over-long list + * itself, carrying the bounding rule, instead of letting an opaque 400 land + * after the spend; and the rule it carries is actionable, because + * `novelty: "org"` already excludes prior DELIVERIES — dropping those is + * normally enough to fit under the cap. + */ + +import { describe, it, expect } from "vitest"; +import { + rejectOversizedExclusions, + MAX_EXCLUDE_LEAD_IDS, +} from "../../../src/composite/_mcp-job-helpers.js"; + +/** Distinct, well-formed uuids — canonicalIdSet drops anything else. */ +function ids(n: number, seed = 0): string[] { + return Array.from({ length: n }, (_, i) => { + const h = (seed * 100000 + i).toString(16).padStart(12, "0"); + return `7b3c1de2-5f40-4a9c-9d21-${h}`; + }); +} + +function rejects(value: unknown): boolean { + try { + rejectOversizedExclusions(value); + return false; + } catch (e) { + expect((e as { code?: string }).code).toBe("TOO_MANY_EXCLUSIONS"); + return true; + } +} + +describe("rejectOversizedExclusions", () => { + it("accepts a list exactly at the cap", () => { + expect(rejects(ids(MAX_EXCLUDE_LEAD_IDS))).toBe(false); + }); + + it("refuses one id over the cap", () => { + expect(rejects(ids(MAX_EXCLUDE_LEAD_IDS + 1))).toBe(true); + }); + + it("refuses the list a wide exploration_cap would build", () => { + // count:50 → exploration_cap ceiling min(20n, 1000) = 1000 candidates, + // so "exclude everything seen" is twice the cap. + expect(rejects(ids(1000))).toBe(true); + }); + + it("counts what would be SENT, not what was passed", () => { + // canonicalIdSet dedupes, so a list that merely repeats itself is not a + // real overflow and must not be refused. + const repeated = [...ids(MAX_EXCLUDE_LEAD_IDS), ...ids(MAX_EXCLUDE_LEAD_IDS)]; + expect(repeated.length).toBeGreaterThan(MAX_EXCLUDE_LEAD_IDS); + expect(rejects(repeated)).toBe(false); + }); + + it("drops blanks but still counts non-uuid entries", () => { + // normalizeUuid only lowercases actual uuids — it passes any other + // non-empty string through, so a malformed id is still SENT and still + // consumes cap. Only blanks disappear. Counting it as free would let a + // list the backend refuses slip past this guard. + expect(rejects([...ids(MAX_EXCLUDE_LEAD_IDS), "", " "])).toBe(false); + expect(rejects([...ids(MAX_EXCLUDE_LEAD_IDS), "not-a-uuid"])).toBe(true); + }); + + it("no-ops on absent exclusions", () => { + expect(rejects(undefined)).toBe(false); + expect(rejects(null)).toBe(false); + expect(rejects([])).toBe(false); + }); + + it("the refusal explains how to get under the cap", () => { + // An error the agent cannot act on just converts a backend 400 into a + // local one. It has to name the half of the list that is redundant. + try { + rejectOversizedExclusions(ids(1000)); + throw new Error("expected a refusal"); + } catch (e) { + const hint = (e as { hint?: string }).hint ?? ""; + expect(hint).toMatch(/novelty/i); + expect(hint).toMatch(/deliver/i); + expect((e as { message?: string }).message).toContain("1000"); + } + }); +}); diff --git a/packages/core/test/unit/composite/exclude-lead-ids-wire.test.ts b/packages/core/test/unit/composite/exclude-lead-ids-wire.test.ts new file mode 100644 index 00000000..009ff36d --- /dev/null +++ b/packages/core/test/unit/composite/exclude-lead-ids-wire.test.ts @@ -0,0 +1,90 @@ +/** + * The exclusion list that is COUNTED is the exclusion list that is SENT. + * + * `rejectOversizedExclusions` counts `canonicalIdSet(ids)` — deduped and + * UUID-folded — while the submit body used to post `params.exclude_lead_ids` + * raw. A 600-entry array that collapses to 400 therefore cleared the local + * guard and was still refused by the backend, on the top-up call the user had + * already paid toward. + * + * The body now posts the canonical list, which also matches what the + * idempotency key was derived from. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { findNewLeads } from "../../../src/composite/find-new-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +const UUID_A = "7b3c1de2-5f40-4a9c-9d21-0c8ea4f61b55"; +const UUID_B = "9f2e8a10-3c77-4b6d-8e12-5a4b7c9d0e33"; + +beforeEach(() => resetHttpMock()); + +async function submittedBody(extra: Record): Promise { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/search", + status: 200, + body: { job_id: "job-1", state: "queued", items: [] }, + }, + { + method: "GET", + path: /^\/1\.6\/mcp\/jobs\//, + status: 200, + body: { + job: { id: "job-1", state: "succeeded" }, + funnel: { delivered: 0, examined: 0 }, + items: [], + }, + }, + ]); + await findNewLeads.execute(newClient(), { + example_lead: { description: "independent gym, 2 sites" }, + count: 5, + request_id: "wire-test", + wait_seconds: 0, + ...extra, + } as any); + const post = getHttpRequests().find( + (r) => r.method === "POST" && r.path.endsWith("/mcp/search") + ); + return JSON.parse(post!.body ?? "{}"); +} + +describe("leadbay_find_new_leads — exclude_lead_ids on the wire", () => { + it("posts the canonical list, not the raw array", async () => { + const body = await submittedBody({ + exclude_lead_ids: [UUID_A, UUID_A.toUpperCase(), UUID_B], + }); + // Deduped case-insensitively — exactly what the cap guard counted. + // (A blank entry is no longer silently dropped here; it is refused + // outright by rejectMalformedExclusions — see exclude-ids-malformed.) + expect(body.exclude_lead_ids).toEqual([UUID_A, UUID_B].sort()); + }); + + it("a list that only repeats itself is sent deduped, not refused", async () => { + const many = Array.from({ length: 600 }, () => UUID_A); + const body = await submittedBody({ exclude_lead_ids: many }); + expect(body.exclude_lead_ids).toEqual([UUID_A]); + }); + + it("omits the key entirely when no exclusions were given", async () => { + // compactBody drops undefined; sending [] instead would be a different + // request body for the same ask. + const body = await submittedBody({}); + expect("exclude_lead_ids" in body).toBe(false); + }); +}); diff --git a/packages/core/test/unit/composite/find-new-leads-idempotency.test.ts b/packages/core/test/unit/composite/find-new-leads-idempotency.test.ts new file mode 100644 index 00000000..3e369622 --- /dev/null +++ b/packages/core/test/unit/composite/find-new-leads-idempotency.test.ts @@ -0,0 +1,221 @@ +/** + * Derived idempotency key for a paid search without an explicit request_id. + * + * The invariant: EVERY field that goes into the POST body and changes what the + * approved search does must also change the key. A field sent to the backend + * but omitted from the hash means two genuinely different approved searches + * collapse onto one job — the backend returns the first as a duplicate and the + * difference (a narrowed filter, a new exclusion list) never takes effect. + * + * The last case below enforces that structurally, so a field added to the body + * later cannot silently drift out of the key. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { findNewLeads } from "../../../src/composite/find-new-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); +const JOB_ID = "c4e8a1b2-77d3-4f60-9a11-3b5c7e2d9f08"; + +const SUBMIT_202 = { + job_id: JOB_ID, + status_url: `/1.6/mcp/jobs/${JOB_ID}`, + estimated_cost: { max: 1880, unit: "cost_cents" }, + items_requested: 10, + duplicate: false, +}; + +const SNAPSHOT = { + job: { id: JOB_ID, state: "completed" }, + funnel: { delivered: 1 }, + items: [], + cost: { spent: 0, unit: "cost_cents", breakdown: {} }, + next_since: null, + explain: { region: "us", model: "m" }, +}; + +beforeEach(() => resetHttpMock()); + +/** Submit a PAID search with no request_id and return the key that went out. */ +async function keyFor(params: Record) { + resetHttpMock(); + mockHttp([ + { method: "POST", path: "/1.6/mcp/search", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: SNAPSHOT, + }, + ]); + await findNewLeads.execute(newClient(), { + count: 10, + qualify: true, + confirm: true, + wait_seconds: 0, + ...params, + } as any); + const post = getHttpRequests().find((r: any) => r.method === "POST")!; + return JSON.parse(post.body!).request_id as string; +} + +describe("find_new_leads — derived search key", () => { + it("is a 128-bit digest", async () => { + expect(await keyFor({})).toMatch(/^search-auto-[0-9a-f]{32}$/); + }); + + it("is stable across identical retries", async () => { + expect(await keyFor({})).toBe(await keyFor({})); + }); + + it("changes when exclude_lead_ids changes", async () => { + // The top-up case: same ask, but avoiding what the first pass returned. + const first = await keyFor({}); + const topUp = await keyFor({ exclude_lead_ids: ["lead-1", "lead-2"] }); + expect(topUp).not.toBe(first); + + const more = await keyFor({ exclude_lead_ids: ["lead-1", "lead-2", "lead-3"] }); + expect(more).not.toBe(topUp); + }); + + it("ignores exclude_lead_ids ORDER", async () => { + const a = await keyFor({ exclude_lead_ids: ["lead-1", "lead-2"] }); + const b = await keyFor({ exclude_lead_ids: ["lead-2", "lead-1"] }); + expect(b).toBe(a); + }); + + // Passing a documented backend default explicitly must NOT fork the key — + // otherwise a retry that materializes defaults launches a second paid job. + it("treats omitted fields and their explicit defaults as the same search", async () => { + const omitted = await keyFor({}); + expect(await keyFor({ novelty: "org" })).toBe(omitted); + expect(await keyFor({ min_ai_score: 0 })).toBe(omitted); + expect(await keyFor({ channels: [] })).toBe(omitted); + expect(await keyFor({ exclude_lead_ids: [] })).toBe(omitted); + + // title_gate defaults to "prefer" only when contact_titles is set. + const withTitles = await keyFor({ contact_titles: ["Owner"] }); + expect(await keyFor({ contact_titles: ["Owner"], title_gate: "prefer" })).toBe( + withTitles + ); + }); + + it("a free-text value cannot forge a field boundary", async () => { + const a = await keyFor({ query: 'x", "count": 99' }); + const b = await keyFor({ query: "x" }); + expect(a).not.toBe(b); + }); + + it("an explicit request_id always wins", async () => { + expect(await keyFor({ request_id: "gyms-dallas-2026-08-05" })).toBe( + "gyms-dallas-2026-08-05" + ); + }); + + // Structural guard: every body field that shapes the search must move the + // key. `request_id` is the key itself and `dry_run` never submits, so both + // are legitimately excluded. + const SHAPING_FIELDS: Array<[string, unknown]> = [ + ["query", "gyms that buy flooring"], + ["example_lead", { description: "an independent single-site gym" }], + ["filters", { sectors: ["fitness"] }], + ["count", 25], + ["min_ai_score", -10], + ["contact_titles", ["Owner"]], + ["title_gate", "strict"], + ["channels", ["email"]], + ["exclude_lead_ids", ["lead-9"]], + ["novelty", "none"], + ["max_cost", 4200], + ["exploration_cap", 90], + ["lang", "fr"], + ]; + + for (const [field, value] of SHAPING_FIELDS) { + it(`changes when ${field} changes`, async () => { + const base = await keyFor({}); + const changed = await keyFor({ [field]: value }); + expect( + changed, + `${field} is sent to the backend but missing from the hashed shape` + ).not.toBe(base); + }); + } +}); + +describe("find_new_leads — canonical shape", () => { + it("nested property ORDER does not fork the key", async () => { + const a = await keyFor({ + example_lead: { description: "an independent gym", employees: 10 }, + }); + const b = await keyFor({ + example_lead: { employees: 10, description: "an independent gym" }, + }); + expect(b).toBe(a); + }); + + it("filter list ORDER does not fork the key", async () => { + const a = await keyFor({ filters: { locations: ["Austin", "Dallas"] } }); + const b = await keyFor({ filters: { locations: ["Dallas", "Austin"] } }); + expect(b).toBe(a); + + const c = await keyFor({ filters: { sectors: ["fitness", "retail"] } }); + const d = await keyFor({ filters: { sectors: ["retail", "fitness"] } }); + expect(d).toBe(c); + }); + + it("filter CONTENT still forks the key", async () => { + const a = await keyFor({ filters: { locations: ["Austin"] } }); + const b = await keyFor({ filters: { locations: ["Dallas"] } }); + expect(b).not.toBe(a); + }); +}); + +describe("find_new_leads — set-valued fields are deduped", () => { + it("duplicate filter values do not fork the key", async () => { + const once = await keyFor({ filters: { locations: ["Dallas"] } }); + const twice = await keyFor({ filters: { locations: ["Dallas", "Dallas"] } }); + expect(twice).toBe(once); + }); + + it("duplicate exclusions and channels do not fork the key", async () => { + const a = await keyFor({ exclude_lead_ids: ["lead-1"] }); + const b = await keyFor({ exclude_lead_ids: ["lead-1", "lead-1"] }); + expect(b).toBe(a); + + const c = await keyFor({ channels: ["email"] }); + const d = await keyFor({ channels: ["email", "email"] }); + expect(d).toBe(c); + }); +}); + +describe("find_new_leads — empty shapes collapse", () => { + it("omitted filters, {} and empty arrays all share a key", async () => { + const omitted = await keyFor({}); + expect(await keyFor({ filters: {} })).toBe(omitted); + expect(await keyFor({ filters: { locations: [] } })).toBe(omitted); + expect(await keyFor({ filters: { sectors: [], locations: [] } })).toBe(omitted); + }); + + it("sector/location casing does not fork the key", async () => { + const a = await keyFor({ filters: { locations: ["Dallas"] } }); + const b = await keyFor({ filters: { locations: ["dallas "] } }); + expect(b).toBe(a); + }); + + it("a non-empty filter still forks the key", async () => { + const omitted = await keyFor({}); + expect(await keyFor({ filters: { locations: ["Dallas"] } })).not.toBe(omitted); + }); +}); diff --git a/packages/core/test/unit/composite/find-new-leads-spend-gate.test.ts b/packages/core/test/unit/composite/find-new-leads-spend-gate.test.ts new file mode 100644 index 00000000..6bc61bf0 --- /dev/null +++ b/packages/core/test/unit/composite/find-new-leads-spend-gate.test.ts @@ -0,0 +1,191 @@ +/** + * Spend gate for leadbay_find_new_leads (Codex P1 on the follow-up review). + * + * qualify_leads got the gate first; this tool has the identical paid surface + * (`qualify: true` and/or `channels`) and was still submitting directly. The + * trigger differs though — `qualify` defaults to FALSE here, so the default + * search is genuinely free and must not be gated. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { findNewLeads } from "../../../src/composite/find-new-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); +const JOB_ID = "9d41c0a6-3b72-4e58-b110-2f7a6c9e4d31"; + +const DRY_RUN_200 = { + valid: true, + items_requested: 10, + estimated_cost: { max: 1880, unit: "cost_cents" }, + quota_forecast: { + web_fetch_allowed: true, + rescore_allowed: true, + enrichment_allowed: true, + }, +}; + +const SUBMIT_202 = { + job_id: JOB_ID, + status_url: `/1.6/mcp/jobs/${JOB_ID}`, + estimated_cost: { max: 0, unit: "cost_cents" }, + items_requested: 10, + duplicate: false, +}; + +const SNAPSHOT = { + job: { id: JOB_ID, state: "completed" }, + funnel: { delivered: 1, examined: 1 }, + items: [ + { + ref: { input_indexes: [0] }, + status: "delivered", + seq: 0, + lead: { lead_id: "aaaa1111-2222-3333-4444-555566667777" }, + }, + ], + cost: { spent: 0, unit: "cost_cents", breakdown: {} }, + next_since: null, + explain: { region: "us", model: "m" }, +}; + +const BASE_ARGS = { count: 10, request_id: "gyms-dallas-2026-08-03" }; + +beforeEach(() => resetHttpMock()); + +const postBodies = () => + getHttpRequests() + .filter((r: any) => r.method === "POST") + .map((r: any) => (typeof r.body === "string" ? JSON.parse(r.body) : r.body)); + +describe("leadbay_find_new_leads — spend gate", () => { + it("withholds a qualify:true search and quotes instead", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/search", status: 200, body: DRY_RUN_200 }, + ]); + + const result: any = await findNewLeads.execute(newClient(), { + ...BASE_ARGS, + qualify: true, + }); + + expect(result.mode).toBe("needs_confirmation"); + expect(result.submitted).toBe(false); + expect(result.job_id).toBeUndefined(); + expect(result.estimated_cost).toEqual({ max: 1880, unit: "cost_cents" }); + + const posts = postBodies(); + expect(posts).toHaveLength(1); + expect(posts[0].dry_run).toBe(true); + }); + + it("withholds a channels purchase even when qualify is off", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/search", status: 200, body: DRY_RUN_200 }, + ]); + + const result: any = await findNewLeads.execute(newClient(), { + ...BASE_ARGS, + channels: ["phone"], + }); + + expect(result.mode).toBe("needs_confirmation"); + expect(result.paid_because.join(" ")).toMatch(/channels requested: phone/); + }); + + it("confirm:false vetoes with no network call at all", async () => { + mockHttp([]); + + const result: any = await findNewLeads.execute(newClient(), { + ...BASE_ARGS, + qualify: true, + confirm: false, + }); + + expect(result.vetoed).toBe(true); + expect(result.quote).toBeNull(); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("the DEFAULT free search is not gated", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/search", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: SNAPSHOT, + }, + ]); + + const result: any = await findNewLeads.execute(newClient(), { + ...BASE_ARGS, + wait_seconds: 0, + }); + + expect(result.mode).toBeUndefined(); + expect(result.job_id).toBe(JOB_ID); + expect(postBodies()[0].dry_run).toBeUndefined(); + }); + + it("synthesizes a request_id when the caller omits the required field", async () => { + // The server does not validate schemas before dispatch, so `required` is + // not enforced — a confirmed paid search could otherwise post with no + // idempotency handle and a retry would launch a second paid job. + mockHttp([ + { method: "POST", path: "/1.6/mcp/search", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: SNAPSHOT, + }, + ]); + + await findNewLeads.execute(newClient(), { + count: 10, + qualify: true, + confirm: true, + wait_seconds: 0, + } as any); + + const body = postBodies()[0]; + expect(body.request_id).toMatch(/^search-auto-[0-9a-f]{32}$/); + }); + + it("confirm:true submits the paid search", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/search", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: SNAPSHOT, + }, + ]); + + const result: any = await findNewLeads.execute(newClient(), { + ...BASE_ARGS, + qualify: true, + confirm: true, + wait_seconds: 0, + }); + + expect(result.job_id).toBe(JOB_ID); + const posts = postBodies(); + expect(posts).toHaveLength(1); + expect(posts[0].dry_run).toBeUndefined(); + // `confirm` is a client-side gate — it must not leak onto the wire. + expect(posts[0].confirm).toBeUndefined(); + }); +}); diff --git a/packages/core/test/unit/composite/find-new-leads.test.ts b/packages/core/test/unit/composite/find-new-leads.test.ts new file mode 100644 index 00000000..bfc083fc --- /dev/null +++ b/packages/core/test/unit/composite/find-new-leads.test.ts @@ -0,0 +1,276 @@ +/** + * Unit tests for leadbay_find_new_leads (POST /mcp/search + job poll). + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { findNewLeads } from "../../../src/composite/find-new-leads.js"; +import { MCP_JOB_POLL } from "../../../src/composite/_mcp-job-helpers.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +const JOB_ID = "281d8b55-b357-43ed-aca9-63e50bce84a6"; + +const SUBMIT_202 = { + job_id: JOB_ID, + status_url: `/1.6/mcp/jobs/${JOB_ID}`, + estimated_cost: { max: 921, unit: "cost_cents" }, + items_requested: 3, + duplicate: false, +}; + +const DELIVERED_ITEM = { + ref: { lead_id: "77b1790c-bd49-4611-bc1e-ce90343a2f32" }, + status: "delivered", + seq: 0, + completed_at: "2026-07-28T10:15:00Z", + cost: { billed: 0, unit: "cost_cents" }, + lead: { + lead_id: "77b1790c-bd49-4611-bc1e-ce90343a2f32", + company: { name: "Black Crow AI", employees: { min: 11, max: 50, known: true } }, + fit: { available: true, score: 59 }, + web_research: { available: false, unavailable_reason: "never_fetched" }, + }, +}; + +const SKIPPED_ITEM = { + ref: { lead_id: "9223bbf6-5270-4c9c-a6f9-6bfa38a8b388" }, + status: "skipped", + status_reason: "disqualified", + seq: 1, + cost: { billed: 0, unit: "cost_cents" }, +}; + +function snapshot(state: string, items: unknown[], stopReason: string | null = null) { + return { + job: { + id: JOB_ID, + state, + submitted_at: "2026-07-28T10:14:02Z", + expires_at: "2026-08-27T10:14:02Z", + last_progress_at: "2026-07-28T10:14:46Z", + }, + funnel: { + matched: 25, + novel: 25, + examined: 2, + qualified: 1, + disqualified: 1, + delivered: items.filter((i: any) => i.status === "delivered").length, + delivered_callable: 0, + delivered_title_only: 0, + degraded: 0, + stop_reason: stopReason, + }, + items, + next_since: items.length > 0 ? "1785233671844264:1" : null, + cost: { spent: 165, unit: "cost_cents", breakdown: { web_fetch_cents: 161, rescore_cents: 4 } }, + explain: { + region: "US", + model: "text_v2_ai_description", + basis: "query_centroid", + seed_strategy: "example_lead", + scope_notes: ["note-1"], + }, + }; +} + +beforeEach(() => resetHttpMock()); + +describe("leadbay_find_new_leads", () => { + it("happy path — submits, polls once, splits delivered/skipped", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/search", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: snapshot("completed", [DELIVERED_ITEM, SKIPPED_ITEM], "target_reached"), + }, + ]); + const result = await findNewLeads.execute(newClient(), { + example_lead: { description: "Operator of full-service fitness centers." }, + filters: { locations: ["Texas"] }, + count: 3, + request_id: "probe-1", + wait_seconds: 0, + }); + + expect(result.job_id).toBe(JOB_ID); + expect(result.done).toBe(true); + expect(result.still_running).toBe(false); + expect(result.next_poll).toBeNull(); + expect(result.leads).toHaveLength(1); + expect(result.leads[0].lead.company.name).toBe("Black Crow AI"); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0].status_reason).toBe("disqualified"); + expect(result.summary.delivered).toBe(1); + expect(result.summary.stop_reason).toBe("target_reached"); + expect(result.explain.scope_notes).toEqual(["note-1"]); + + const submit = getHttpRequests().find((r) => r.method === "POST")!; + const body = JSON.parse(submit.body!); + expect(body.request_id).toBe("probe-1"); + expect(body.example_lead.description).toMatch(/fitness centers/); + // Local-only params never reach the wire. + expect(body.wait_seconds).toBeUndefined(); + }); + + it("dry_run — forecasts without creating or polling a job", async () => { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/search", + status: 200, + body: { + valid: true, + items_requested: 3, + estimated_cost: { max: 921, unit: "cost_cents" }, + quota_forecast: { web_fetch_allowed: true, rescore_allowed: true, enrichment_allowed: true }, + }, + }, + ]); + const result = await findNewLeads.execute(newClient(), { + query: "gyms in Texas", + count: 3, + request_id: "probe-dry", + dry_run: true, + }); + expect(result.dry_run).toBe(true); + expect(result.estimated_cost.max).toBe(921); + expect(getHttpRequests()).toHaveLength(1); + }); + + it("still-running job — returns partial results with an explicit next_poll", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/search", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: snapshot("running", [DELIVERED_ITEM]), + }, + ]); + const result = await findNewLeads.execute(newClient(), { + example_lead: { description: "SaaS analytics for retailers." }, + count: 3, + request_id: "probe-2", + wait_seconds: 0, + }); + expect(result.done).toBe(false); + expect(result.still_running).toBe(true); + // toMatchObject, not toEqual: the handoff also carries a `since` cursor so + // the follow-up poll is incremental, and pinning the exact shape here just + // breaks on every future field. + expect(result.next_poll).toMatchObject({ + tool: "leadbay_lead_job_status", + job_id: JOB_ID, + suggested_wait_seconds: 60, + }); + expect(result.leads).toHaveLength(1); + }); + + it("wait loop — keeps polling until the job goes terminal", async () => { + const restoreInterval = MCP_JOB_POLL.intervalMs; + MCP_JOB_POLL.intervalMs = 1; + try { + mockHttp([ + { method: "POST", path: "/1.6/mcp/search", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: snapshot("running", []), + }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: snapshot("completed", [DELIVERED_ITEM], "target_reached"), + }, + ]); + const result = await findNewLeads.execute(newClient(), { + example_lead: { description: "SaaS analytics for retailers." }, + count: 3, + request_id: "probe-3", + wait_seconds: 5, + }); + expect(result.done).toBe(true); + expect(result.leads).toHaveLength(1); + expect(getHttpRequests()).toHaveLength(3); + } finally { + MCP_JOB_POLL.intervalMs = restoreInterval; + } + }); + + it("normalizes an agent-invented nested filters.employees onto the flat wire keys", async () => { + // Live eval 2026-07-30: 2/2 cold agents passed employees:{min,max} + // (the RESULT shape) and the backend 400'd the whole ask. The composite + // maps it instead of failing. + mockHttp([ + { method: "POST", path: "/1.6/mcp/search", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: snapshot("completed", [DELIVERED_ITEM], "target_reached"), + }, + ]); + await findNewLeads.execute(newClient(), { + example_lead: { description: "B2B SaaS with in-house sales teams." }, + filters: { locations: ["Texas"], employees: { min: 100, max: 1000 } } as any, + count: 3, + request_id: "probe-nested-emp", + wait_seconds: 0, + }); + const body = JSON.parse(getHttpRequests().find((r) => r.method === "POST")!.body!); + expect(body.filters).toEqual({ + locations: ["Texas"], + employees_min: 100, + employees_max: 1000, + }); + }); + + it("rejects country-level locations with a named, actionable error before any spend", async () => { + // 4/4 live E2E agents passed a country label; the backend silently + // fences it to a same-named town (product#3939). + mockHttp([]); + await expect( + findNewLeads.execute(newClient(), { + example_lead: { description: "College with employer-facing B2B programs." }, + filters: { locations: ["United States"] }, + count: 5, + request_id: "probe-country", + }) + ).rejects.toMatchObject({ code: "COUNTRY_LEVEL_LOCATION" }); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("429 refusal (rate cap) — propagates as a quota error", async () => { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/search", + status: 429, + body: { error: "rate_limited", message: "submit rate cap reached" }, + }, + ]); + await expect( + findNewLeads.execute(newClient(), { + query: "gyms", + count: 3, + request_id: "probe-4", + }) + ).rejects.toMatchObject({ code: "QUOTA_EXCEEDED" }); + }); +}); diff --git a/packages/core/test/unit/composite/job-drain-search-sized.test.ts b/packages/core/test/unit/composite/job-drain-search-sized.test.ts new file mode 100644 index 00000000..e8646f40 --- /dev/null +++ b/packages/core/test/unit/composite/job-drain-search-sized.test.ts @@ -0,0 +1,73 @@ +/** + * The page drain must be sized for the LARGEST job, not just qualify. + * + * `maxPagesFor` derives its bound from MAX_JOB_ITEMS. That constant was 500 — + * a qualify job's ref ceiling — but a SEARCH may examine up to + * `exploration_cap`'s ceiling of min(20n, 1000) candidates and emit an outcome + * for each. With a small `limit` on `leadbay_lead_job_status`, the drain then + * stopped mid-job and still returned a terminal snapshot with no cursor, so + * paid deliveries — and the rejected ids the top-up needs for + * `exclude_lead_ids` — silently never reached the render. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { collectJobSnapshot } from "../../../src/composite/_mcp-job-helpers.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +beforeEach(() => resetHttpMock()); + +/** A job that emits `total` outcomes, one item per page (limit=1) — the worst + * case for the page bound, and the one Codex named. */ +function mockPagedJob(total: number) { + const pages = Array.from({ length: total }, (_, i) => ({ + method: "GET" as const, + path: /^\/1\.6\/mcp\/jobs\//, + status: 200, + body: { + job: { id: "job-1", state: "completed" }, + funnel: { delivered: total, examined: total }, + items: [{ status: "delivered", seq: i + 1 }], + next_since: i + 1 < total ? `cur-${i + 1}` : null, + cost: { spent: 0, unit: "cost_cents", breakdown: {} }, + explain: { region: "us", model: "m" }, + }, + })); + mockHttp(pages); +} + +describe("collectJobSnapshot — drain sized for a wide search", () => { + it("drains a 1000-outcome search at limit=1 without truncating", async () => { + // count:50 -> exploration_cap ceiling min(20n, 1000). The old bound of 500 + // stopped here at 501 pages and reported the job done. + mockPagedJob(1000); + const snap = await collectJobSnapshot(newClient(), "job-1", undefined, 1); + expect(snap.items).toHaveLength(1000); + }); + + it("still drains the qualify ceiling of 500", async () => { + mockPagedJob(500); + const snap = await collectJobSnapshot(newClient(), "job-1", undefined, 1); + expect(snap.items).toHaveLength(500); + }); + + it("stops as soon as a short page says the cursor is caught up", async () => { + // The bound is a backstop, not the drain signal — a short page must still + // end it immediately rather than burning the full page budget. + mockPagedJob(3); + const snap = await collectJobSnapshot(newClient(), "job-1", undefined, 1); + expect(snap.items).toHaveLength(3); + expect(getHttpRequests().filter((r) => r.method === "GET")).toHaveLength(3); + }); +}); diff --git a/packages/core/test/unit/composite/job-id-traversal.test.ts b/packages/core/test/unit/composite/job-id-traversal.test.ts new file mode 100644 index 00000000..b7518121 --- /dev/null +++ b/packages/core/test/unit/composite/job-id-traversal.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, httpsMockFactory, getHttpRequests } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { collectJobSnapshot, assertSafeJobId } from "../../../src/composite/_mcp-job-helpers.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.test-token", "us"); + +beforeEach(() => resetHttpMock()); + +describe("job_id path-traversal guard", () => { + // The regression this exists for: encodeURIComponent does NOT escape `.`, so + // `..` reached the URL verbatim and `new URL()` normalized + // /1.6/mcp/jobs/..?limit=100 down to /1.6/mcp/?limit=100 — sending the bearer + // token to an endpoint the caller never asked for. + it.each([[".."], ["."], ["..."]])("rejects the dot segment %j", async (jobId) => { + mockHttp([]); + await expect( + collectJobSnapshot(newClient(), jobId) + ).rejects.toMatchObject({ code: "INVALID_JOB_ID" }); + // Nothing may reach the wire: the whole point is that the token never goes + // to the re-pointed path. + expect(getHttpRequests()).toHaveLength(0); + }); + + it.each([["a/b"], ["../users/me"], ["x?y=1"], ["x#f"], ["x y"], [""]])( + "rejects the malformed handle %j without calling the API", + async (jobId) => { + mockHttp([]); + await expect( + collectJobSnapshot(newClient(), jobId) + ).rejects.toMatchObject({ code: "INVALID_JOB_ID" }); + expect(getHttpRequests()).toHaveLength(0); + } + ); + + it("rejects a non-string handle", async () => { + mockHttp([]); + await expect( + collectJobSnapshot(newClient(), undefined as unknown as string) + ).rejects.toMatchObject({ code: "INVALID_JOB_ID" }); + }); + + it("rejects an absurdly long handle", () => { + expect(() => assertSafeJobId("a".repeat(201))).toThrow(); + expect(() => assertSafeJobId("a".repeat(200))).not.toThrow(); + }); + + it("still accepts the handles the backend actually issues", async () => { + const ids = [ + "0a2fcbf5-18e1-4967-b5de-0c67cd823bcc", + "search-auto-gyms-dallas-2026-07-28", + "qualify-auto-abc_123", + "j1", + ]; + for (const id of ids) { + resetHttpMock(); + mockHttp([ + { + method: "GET", + path: `/1.6/mcp/jobs/${id}?limit=100`, + status: 200, + body: { + job: { state: "running" }, + items: [], + funnel: {}, + cost: { spent: 0 }, + next_since: null, + }, + }, + ]); + await collectJobSnapshot(newClient(), id); + // Asserting the PATH, not just that it succeeded: a handle that survives + // validation must also land on /mcp/jobs/ unchanged. + expect(getHttpRequests()[0].path).toBe(`/1.6/mcp/jobs/${id}?limit=100`); + } + }); +}); diff --git a/packages/core/test/unit/composite/job-poll-abort-inflight.test.ts b/packages/core/test/unit/composite/job-poll-abort-inflight.test.ts new file mode 100644 index 00000000..b33be5ee --- /dev/null +++ b/packages/core/test/unit/composite/job-poll-abort-inflight.test.ts @@ -0,0 +1,105 @@ +/** + * Cancellation must reach the in-flight request, not just the sleep between + * polls. + * + * Making the delay abortable was only half of it: `waitForJob` still checked + * `ctx.signal` only AFTER awaiting `collectJobSnapshot`, and the snapshot's GET + * took no signal at all. A cancel arriving before or during a slow `/mcp/jobs` + * response therefore sat blocked until the server answered — well past the + * "polling loop exits within <=2 seconds" the server advertises in its own + * instructions. + * + * Three properties pinned here: an already-cancelled wait opens no request at + * all, the signal reaches the HTTP layer, and a cancel mid-drain stops paging. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { + waitForJob, + collectJobSnapshot, +} from "../../../src/composite/_mcp-job-helpers.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +beforeEach(() => resetHttpMock()); + +const runningPage = (n: number) => ({ + method: "GET" as const, + path: /^\/1\.6\/mcp\/jobs\//, + status: 200, + body: { + job: { id: "job-1", state: "running" }, + funnel: { delivered: n, examined: n }, + items: [{ status: "delivered", seq: n }], + next_since: `cur-${n}`, + cost: { spent: 0, unit: "cost_cents", breakdown: {} }, + explain: { region: "us", model: "m" }, + }, +}); + +describe("waitForJob — cancellation reaches the request", () => { + it("opens NO request when the signal is already aborted", async () => { + mockHttp([runningPage(1)]); + const ac = new AbortController(); + ac.abort(); + await expect( + waitForJob(newClient(), "job-1", 60, { signal: ac.signal } as any) + ).rejects.toMatchObject({ code: "REQUEST_CANCELLED" }); + // The point: not one byte went out for a wait nobody is listening to. + expect(getHttpRequests()).toHaveLength(0); + }); + + it("says the job keeps running, since cancelling the wait does not stop it", async () => { + mockHttp([runningPage(1)]); + const ac = new AbortController(); + ac.abort(); + const err = await waitForJob( + newClient(), + "job-1", + 60, + { signal: ac.signal } as any + ).catch((e) => e); + expect(err.hint).toMatch(/backend-owned|keeps running/i); + expect(err.hint).toMatch(/leadbay_lead_job_status/); + }); +}); + +describe("collectJobSnapshot — abort stops the drain", () => { + it("stops paging once the signal aborts", async () => { + // Full pages keep the drain going; aborting after the first must end it + // rather than walking every page the bound allows. + mockHttp([runningPage(1), runningPage(2), runningPage(3)]); + const ac = new AbortController(); + const client = newClient(); + const p = collectJobSnapshot(client, "job-1", undefined, 1, ac.signal); + ac.abort(); + const snap = await p.catch(() => null); + // Either it rejected (abort hit the socket) or it stopped early — what it + // must NOT do is drain all three pages as if nothing happened. + const gets = getHttpRequests().filter((r) => r.method === "GET"); + expect(gets.length).toBeLessThan(3); + if (snap) expect(snap.items.length).toBeLessThan(3); + }); + + it("still drains normally with no signal", async () => { + // Last page carries no cursor, which is what ends a normal drain. + const lastPage = { + ...runningPage(2), + body: { ...runningPage(2).body, next_since: null }, + }; + mockHttp([runningPage(1), lastPage]); + const snap = await collectJobSnapshot(newClient(), "job-1", undefined, 1); + expect(snap.items.length).toBe(2); + }); +}); diff --git a/packages/core/test/unit/composite/job-poll-cancellation.test.ts b/packages/core/test/unit/composite/job-poll-cancellation.test.ts new file mode 100644 index 00000000..9eacb5c2 --- /dev/null +++ b/packages/core/test/unit/composite/job-poll-cancellation.test.ts @@ -0,0 +1,74 @@ +/** + * The poll delay must observe cancellation, not outlive it. + * + * `buildProtocolPrimitivesParagraph` tells every client that on Cancel "the + * polling loop exits within ≤2 seconds". The job poller slept on a bare + * `setTimeout(MCP_JOB_POLL.intervalMs)` — 4000ms — and only re-checked + * `ctx.signal.aborted` after the timer fired. A cancel landing just after a + * poll therefore waited out the full interval: twice the advertised bound, + * on a promise the server makes in its own instructions. + */ + +import { describe, it, expect, vi } from "vitest"; +import { sleepUnlessAborted } from "../../../src/composite/_mcp-job-helpers.js"; + +describe("sleepUnlessAborted", () => { + it("resolves early when the signal aborts mid-sleep", async () => { + const ac = new AbortController(); + const started = Date.now(); + const sleeping = sleepUnlessAborted(4000, ac.signal); + setTimeout(() => ac.abort(), 20); + await sleeping; + // Generous bound — the point is "nowhere near 4000", not a tight timing + // assertion that would flake on a loaded CI box. + expect(Date.now() - started).toBeLessThan(1000); + }); + + it("returns immediately when the signal is already aborted", async () => { + const ac = new AbortController(); + ac.abort(); + const started = Date.now(); + await sleepUnlessAborted(4000, ac.signal); + expect(Date.now() - started).toBeLessThan(200); + }); + + it("still sleeps the full duration with no signal", async () => { + vi.useFakeTimers(); + try { + let done = false; + const sleeping = sleepUnlessAborted(4000).then(() => { + done = true; + }); + await vi.advanceTimersByTimeAsync(3999); + expect(done).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await sleeping; + expect(done).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("removes its abort listener so a long poll loop cannot leak them", async () => { + // waitForJob calls this once per poll against ONE signal. Leaving the + // listener attached would accumulate one per iteration for the life of the + // request, and Node warns at 11. + const ac = new AbortController(); + const added: string[] = []; + const removed: string[] = []; + const realAdd = ac.signal.addEventListener.bind(ac.signal); + const realRemove = ac.signal.removeEventListener.bind(ac.signal); + ac.signal.addEventListener = ((t: string, ...rest: unknown[]) => { + added.push(t); + return (realAdd as never as (...a: unknown[]) => void)(t, ...rest); + }) as typeof ac.signal.addEventListener; + ac.signal.removeEventListener = ((t: string, ...rest: unknown[]) => { + removed.push(t); + return (realRemove as never as (...a: unknown[]) => void)(t, ...rest); + }) as typeof ac.signal.removeEventListener; + + for (let i = 0; i < 5; i++) await sleepUnlessAborted(1, ac.signal); + expect(added.filter((t) => t === "abort")).toHaveLength(5); + expect(removed.filter((t) => t === "abort")).toHaveLength(5); + }); +}); diff --git a/packages/core/test/unit/composite/job-wait-deadline.test.ts b/packages/core/test/unit/composite/job-wait-deadline.test.ts new file mode 100644 index 00000000..b4414363 --- /dev/null +++ b/packages/core/test/unit/composite/job-wait-deadline.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { LeadbayClient } from "../../../src/client.js"; +import { + waitForJob, + collectJobSnapshot, + SNAPSHOT_TIMEOUT_MS, + MCP_JOB_POLL, +} from "../../../src/composite/_mcp-job-helpers.js"; + +type Opts = { signal?: AbortSignal; timeoutMs?: number; totalTimeoutMs?: number }; + +const snapshot = (state: string) => ({ + job: { state }, + items: [], + funnel: {}, + cost: { spent: 0 }, + next_since: null, +}); + +// Records the opts of every request so the deadline can be asserted where it +// actually matters — on the wire call, not on the helper's signature. +function stubClient( + handler: (call: number) => unknown +): { client: LeadbayClient; opts: Opts[] } { + const opts: Opts[] = []; + let call = 0; + const client = { + request: async (_m: string, _p: string, _b: unknown, o?: Opts) => { + opts.push(o ?? {}); + const out = handler(call++); + if (out instanceof Error) throw out; + return out; + }, + } as unknown as LeadbayClient; + return { client, opts }; +} + +const timeoutErr = () => { + const e = new Error("Request timed out after 1000ms: GET /x") as Error & { code?: string }; + e.code = "TIMEOUT"; + return e; +}; + +// Use the documented cadence seam so the multi-poll cases don't sleep for real. +const REAL_INTERVAL = MCP_JOB_POLL.intervalMs; +beforeEach(() => { + MCP_JOB_POLL.intervalMs = 5; +}); +afterEach(() => { + MCP_JOB_POLL.intervalMs = REAL_INTERVAL; +}); + +describe("job snapshots are bounded by the caller's wait budget", () => { + it("bounds the FIRST snapshot, not just the ones after the loop condition", async () => { + const { client, opts } = stubClient(() => snapshot("completed")); + await waitForJob(client, "job-1", 5); + + expect(opts).toHaveLength(1); + // Before the fix this was undefined: wait_seconds was enforced only by the + // loop condition, which is not evaluated until the first GET has returned. + expect(opts[0].totalTimeoutMs).toBeDefined(); + expect(opts[0].totalTimeoutMs!).toBeLessThanOrEqual(5000); + }); + + it("never lets a snapshot outlive the requested wait", async () => { + const { client, opts } = stubClient(() => snapshot("completed")); + await waitForJob(client, "job-1", 1); + expect(opts[0].totalTimeoutMs!).toBeLessThanOrEqual(1000); + }); + + it("caps a generous wait at the per-request ceiling", async () => { + const { client, opts } = stubClient(() => snapshot("completed")); + await waitForJob(client, "job-1", 600); + expect(opts[0].totalTimeoutMs).toBe(SNAPSHOT_TIMEOUT_MS); + }); + + it("gives a zero-wait poll a bound of its own", async () => { + const { client, opts } = stubClient(() => snapshot("running")); + await collectJobSnapshot(client, "job-1"); + expect(opts[0].totalTimeoutMs).toBe(SNAPSHOT_TIMEOUT_MS); + }); + + it("keeps the job_id when the first read times out, rather than losing a paid job", async () => { + const { client } = stubClient(() => timeoutErr()); + await expect(waitForJob(client, "job-abc", 2)).rejects.toMatchObject({ + code: "JOB_READ_TIMEOUT", + }); + // The handle must be recoverable from the error itself. + await waitForJob(client, "job-abc", 2).catch((e) => { + expect(e.message).toContain("job-abc"); + expect(e.hint).toContain("job-abc"); + expect(e.hint).toContain("leadbay_lead_job_status"); + }); + }); + + it("returns the last good snapshot when a LATER read times out", async () => { + // First read succeeds (running), the follow-up poll times out. + const { client } = stubClient((n) => (n === 0 ? snapshot("running") : timeoutErr())); + const snap = await waitForJob(client, "job-1", 5); + // A live job must not be discarded over one slow read. + expect(snap.job.state).toBe("running"); + }); + + it("propagates a non-timeout failure untouched", async () => { + const { client } = stubClient(() => new Error("boom")); + await expect(waitForJob(client, "job-1", 2)).rejects.toThrow("boom"); + }); +}); + +describe("the wait bounds the whole drain, not each page of it", () => { + const page = (n: number, full: boolean) => ({ + job: { state: "completed" }, + items: full ? Array.from({ length: 2 }, (_, i) => ({ id: `${n}-${i}` })) : [], + funnel: {}, + cost: { spent: 0 }, + next_since: full ? `cur-${n}` : null, + }); + + it("spends one budget across the pages instead of handing each page a fresh one", async () => { + // Every page comes back FULL with a cursor, so the drain would run until + // maxPages. Per-request timeouts let each of those pages claim the caller's + // whole wait_seconds — the budget multiplied by the page count. + // + // The clock MUST advance for this to mean anything: with a frozen clock + // `remaining()` and a per-page `timeoutMs` are indistinguishable, and the + // test passes against the very bug it is meant to catch. + let now = 0; + const spy = vi.spyOn(Date, "now").mockImplementation(() => now); + try { + const { client, opts } = stubClient((n) => { + now += 100; + return page(n, true); + }); + await waitForJob(client, "job-1", 5, undefined, undefined, undefined, 2); + + expect(opts.length).toBeGreaterThan(1); + const budgets = opts.map((o) => o.totalTimeoutMs!); + // STRICTLY decreasing: each page is bounded by what is LEFT, so the drain + // as a whole cannot outlast the wait. A per-page budget would hold flat. + for (let i = 1; i < budgets.length; i++) { + expect(budgets[i]).toBeLessThan(budgets[i - 1]); + } + expect(Math.max(...budgets)).toBeLessThanOrEqual(5000); + // And the total handed out must not exceed the wait, which is precisely + // what "one budget per page" violated. + expect(budgets[budgets.length - 1]).toBeLessThanOrEqual(5000 - 100 * (budgets.length - 1)); + } finally { + spy.mockRestore(); + } + }); + + it("stops paging once the budget is spent rather than starting a doomed page", async () => { + let now = 0; + const spy = vi.spyOn(Date, "now").mockImplementation(() => now); + try { + // Each page consumes 400ms of a 1s budget. + const { client, opts } = stubClient((n) => { + now += 400; + return page(n, true); + }); + const snap = await waitForJob(client, "job-1", 1, undefined, undefined, undefined, 2); + // 1000ms / 400ms — the drain must stop, not run to maxPages. + expect(opts.length).toBeLessThanOrEqual(3); + // And it must SAY it stopped early: a full last page plus a cursor is a + // prefix, not a finished read. + expect(snap.items_truncated).toBe(true); + expect(snap.next_since).toBeTruthy(); + } finally { + spy.mockRestore(); + } + }); + + it("does not flag a drain that finished naturally", async () => { + // Short page = the cursor ran dry. + const { client } = stubClient(() => page(0, false)); + const snap = await waitForJob(client, "job-1", 5, undefined, undefined, undefined, 2); + expect(snap.items_truncated).toBeUndefined(); + }); +}); diff --git a/packages/core/test/unit/composite/lead-job-status-cursor.test.ts b/packages/core/test/unit/composite/lead-job-status-cursor.test.ts new file mode 100644 index 00000000..8caea601 --- /dev/null +++ b/packages/core/test/unit/composite/lead-job-status-cursor.test.ts @@ -0,0 +1,137 @@ +/** + * Cursor propagation through the block-waiting path of leadbay_lead_job_status. + * + * `since` + `wait_seconds > 0` used to drop the cursor: waitForJob took no + * since/limit, so the promised incremental poll silently became a full + * limit=100 snapshot that re-emitted every already-seen lead. The tool's own + * description promises both behaviours at once, so they must compose. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { leadJobStatus } from "../../../src/composite/lead-job-status.js"; +import { MCP_JOB_POLL } from "../../../src/composite/_mcp-job-helpers.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +const JOB_ID = "3f0a91cc-77b2-4de6-9a10-1e5b7c2d8a44"; +const CURSOR = "2026-07-28T10:20:00Z"; + +const TERMINAL_SNAPSHOT = { + job: { job_id: JOB_ID, state: "completed" }, + funnel: { delivered: 1, examined: 1 }, + items: [ + { + ref: { input_indexes: [0] }, + status: "delivered", + seq: 7, + lead: { lead_id: "aaaa1111-2222-3333-4444-555566667777" }, + }, + ], + cost: { spent: 94, unit: "cost_cents" }, + next_since: "2026-07-28T10:25:00Z", +}; + +beforeEach(() => resetHttpMock()); + +const getPaths = () => + getHttpRequests() + .filter((r: any) => r.method === "GET") + .map((r: any) => r.path as string); + +describe("leadbay_lead_job_status — cursor through the wait path", () => { + it("forwards since + limit when block-waiting", async () => { + // Terminal on the first read, so waitForJob returns after one snapshot. + mockHttp([ + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=25&since=${encodeURIComponent(CURSOR)}`, + status: 200, + body: TERMINAL_SNAPSHOT, + }, + ]); + + const result: any = await leadJobStatus.execute(newClient(), { + job_id: JOB_ID, + since: CURSOR, + limit: 25, + wait_seconds: 30, + }); + + expect(result.done).toBe(true); + + const paths = getPaths(); + expect(paths).toHaveLength(1); + expect(paths[0]).toContain(`since=${encodeURIComponent(CURSOR)}`); + expect(paths[0]).toContain("limit=25"); + }); + + it("still forwards since + limit on the non-waiting path", async () => { + mockHttp([ + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=25&since=${encodeURIComponent(CURSOR)}`, + status: 200, + body: TERMINAL_SNAPSHOT, + }, + ]); + + await leadJobStatus.execute(newClient(), { + job_id: JOB_ID, + since: CURSOR, + limit: 25, + wait_seconds: 0, + }); + + const paths = getPaths(); + expect(paths[0]).toContain(`since=${encodeURIComponent(CURSOR)}`); + expect(paths[0]).toContain("limit=25"); + }); +}); + +describe("leadbay_lead_job_status — wait bound", () => { + it("does not sleep a full interval past a short wait_seconds", async () => { + // A running job never goes terminal, so only the deadline stops the loop. + const RUNNING = { + ...TERMINAL_SNAPSHOT, + job: { job_id: JOB_ID, state: "running" }, + }; + mockHttp( + Array.from({ length: 12 }, () => ({ + method: "GET" as const, + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: RUNNING, + })) + ); + + // Real 4s intervals would make this test unusable; shrink the cadence and + // assert the loop respects a deadline SHORTER than one interval. + const original = MCP_JOB_POLL.intervalMs; + MCP_JOB_POLL.intervalMs = 400; + try { + const startedAt = Date.now(); + const result: any = await leadJobStatus.execute(newClient(), { + job_id: JOB_ID, + wait_seconds: 0.2, + }); + const elapsed = Date.now() - startedAt; + + expect(result.still_running).toBe(true); + // Bounded by the deadline (0.2s), not by the 400ms interval. + expect(elapsed).toBeLessThan(400); + } finally { + MCP_JOB_POLL.intervalMs = original; + } + }); +}); diff --git a/packages/core/test/unit/composite/lead-job-status.test.ts b/packages/core/test/unit/composite/lead-job-status.test.ts new file mode 100644 index 00000000..45cb132c --- /dev/null +++ b/packages/core/test/unit/composite/lead-job-status.test.ts @@ -0,0 +1,124 @@ +/** + * Unit tests for leadbay_lead_job_status (GET /mcp/jobs/{id} snapshot). + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { leadJobStatus } from "../../../src/composite/lead-job-status.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +const JOB_ID = "d89b9803-f9d8-4298-86af-9cd4b1841afd"; + +function item(seq: number, status = "delivered") { + return { + ref: { lead_id: `00000000-0000-0000-0000-00000000000${seq}` }, + status, + seq, + completed_at: "2026-07-28T10:30:00Z", + cost: { billed: 0, unit: "cost_cents" }, + lead: + status === "skipped" + ? undefined + : { lead_id: `00000000-0000-0000-0000-00000000000${seq}`, company: { name: `Co ${seq}` } }, + }; +} + +function page(state: string, items: unknown[], nextSince: string | null) { + return { + job: { + id: JOB_ID, + state, + submitted_at: "2026-07-28T10:29:00Z", + expires_at: "2026-08-27T10:29:00Z", + last_progress_at: "2026-07-28T10:30:00Z", + }, + funnel: { delivered: 3, examined: 4 }, + items, + next_since: nextSince, + cost: { spent: 0, unit: "cost_cents", breakdown: {} }, + explain: { region: "US", model: "text_v2_ai_description", scope_notes: [] }, + }; +} + +beforeEach(() => resetHttpMock()); + +describe("leadbay_lead_job_status", () => { + it("terminal snapshot — splits leads/skipped, no next_poll", async () => { + mockHttp([ + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: page("completed", [item(0), item(1, "skipped")], "100:1"), + }, + ]); + const result = await leadJobStatus.execute(newClient(), { job_id: JOB_ID }); + expect(result.done).toBe(true); + expect(result.leads).toHaveLength(1); + expect(result.skipped).toHaveLength(1); + expect(result.next_poll).toBeNull(); + expect(result.next_since).toBe("100:1"); + }); + + it("pages the cursor dry when a page comes back full", async () => { + mockHttp([ + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=2`, + status: 200, + body: page("completed", [item(0), item(1)], "100:1"), + }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=2&since=${encodeURIComponent("100:1")}`, + status: 200, + body: page("completed", [item(2)], "101:2"), + }, + ]); + const result = await leadJobStatus.execute(newClient(), { + job_id: JOB_ID, + limit: 2, + }); + expect(result.leads).toHaveLength(3); + expect(getHttpRequests()).toHaveLength(2); + }); + + it("running job — still_running with next_poll handle", async () => { + mockHttp([ + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: page("running", [item(0)], "100:0"), + }, + ]); + const result = await leadJobStatus.execute(newClient(), { job_id: JOB_ID }); + expect(result.still_running).toBe(true); + expect(result.next_poll).toMatchObject({ tool: "leadbay_lead_job_status", job_id: JOB_ID }); + }); + + it("unknown job — 404 propagates", async () => { + mockHttp([ + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 404, + body: { error: "not_found", message: "job" }, + }, + ]); + await expect( + leadJobStatus.execute(newClient(), { job_id: JOB_ID }) + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); diff --git a/packages/core/test/unit/composite/mcp-job-helpers-review.test.ts b/packages/core/test/unit/composite/mcp-job-helpers-review.test.ts new file mode 100644 index 00000000..c818dc7f --- /dev/null +++ b/packages/core/test/unit/composite/mcp-job-helpers-review.test.ts @@ -0,0 +1,267 @@ +/** + * Review fixes on the shared MCP-job plumbing (Codex pass on PR #168). + * + * Three separate defects, all in _mcp-job-helpers.ts: + * - a flat 20-page stop truncated small-page drains while reporting done + * - an empty drain page overwrote the resumption cursor with null + * - the country guard exact-matched, so "the United States" / "U.S" / + * "les États-Unis" sailed through to silent same-named-town fencing + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { + collectJobSnapshot, + rejectCountryLocations, + mockedSubmitPreview, + normalizeSearchFilters, + canonicalSet, + canonicalLabelSet, + canonicalIdSet, + normalizeUuid, +} from "../../../src/composite/_mcp-job-helpers.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); +const JOB_ID = "5c2f0b7a-9e11-4d33-8a06-77b1c4e2f900"; + +const item = (seq: number) => ({ + ref: { input_indexes: [seq] }, + status: "delivered", + seq, + lead: { lead_id: `lead-${seq}` }, +}); + +const page = (items: any[], next: string | null) => ({ + job: { id: JOB_ID, state: "completed" }, + funnel: { delivered: items.length }, + items, + next_since: next, + cost: { spent: 0, unit: "cost_cents", breakdown: {} }, + explain: { region: "us", model: "m" }, +}); + +beforeEach(() => resetHttpMock()); + +describe("collectJobSnapshot — page bound scales with page size", () => { + it("drains a small-page job past the old flat 20-page stop", async () => { + // limit=5 over 300 items = 60 pages. The old flat MAX_PAGES=20 returned + // 100 items and still reported the job complete. + const TOTAL = 300; + const SIZE = 5; + const pages = []; + for (let start = 0; start < TOTAL; start += SIZE) { + const items = Array.from({ length: SIZE }, (_, i) => item(start + i)); + const isLast = start + SIZE >= TOTAL; + pages.push({ + method: "GET" as const, + path: + `/1.6/mcp/jobs/${JOB_ID}?limit=${SIZE}` + + (start === 0 ? "" : `&since=${encodeURIComponent(`cur-${start}`)}`), + status: 200, + body: page(items, isLast ? null : `cur-${start + SIZE}`), + }); + } + mockHttp(pages); + + const snap = await collectJobSnapshot(newClient(), JOB_ID, undefined, SIZE); + expect(snap.items).toHaveLength(TOTAL); + }); +}); + +describe("collectJobSnapshot — cursor survives an empty drain page", () => { + it("keeps the last non-empty page's cursor when the next page is empty", async () => { + mockHttp([ + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=2`, + status: 200, + body: page([item(0), item(1)], "cur-2"), + }, + { + // The drain page: no items, and the backend nulls the cursor. + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=2&since=${encodeURIComponent("cur-2")}`, + status: 200, + body: page([], null), + }, + ]); + + const snap = await collectJobSnapshot(newClient(), JOB_ID, undefined, 2); + expect(snap.items).toHaveLength(2); + // Without the fix this was null and the caller had to re-read from zero. + expect(snap.next_since).toBe("cur-2"); + }); +}); + +describe("rejectCountryLocations — alias normalization", () => { + const rejects = [ + "United States", + "the United States", + "U.S", + "U.S.", + "U.S.A.", + "USA", + "America", + "les États-Unis", + "États-Unis", + "etats-unis", + "la France", + "France", + "République Française", + " us ", + ]; + + for (const value of rejects) { + it(`rejects ${JSON.stringify(value)}`, () => { + expect(() => rejectCountryLocations([value])).toThrow( + expect.objectContaining({ code: "COUNTRY_LEVEL_LOCATION" }) + ); + }); + } + + // The guard must not swallow legitimate places — Île-de-France in + // particular must not be mistaken for France. + const allows = [ + "Austin", + "New York", + "Paris", + "Île-de-France", + "Los Angeles", + "Texas", + "Kansas City", + ]; + + for (const value of allows) { + it(`allows ${JSON.stringify(value)}`, () => { + expect(() => rejectCountryLocations([value])).not.toThrow(); + }); + } + + it("ignores a non-array argument", () => { + expect(() => rejectCountryLocations(undefined)).not.toThrow(); + }); +}); + +describe("mockedSubmitPreview", () => { + it("returns a preview when the submit carried no job_id (LEADBAY_MOCK)", () => { + const previous = process.env.LEADBAY_MOCK; + process.env.LEADBAY_MOCK = "1"; + try { + const out = mockedSubmitPreview( + { mocked: true, would_call: { method: "POST", path: "/1.6/mcp/search" } }, + "leadbay_find_new_leads", + "us" + ); + expect(out).not.toBeNull(); + expect(out!.submitted).toBe(false); + expect(out!.tool).toBe("leadbay_find_new_leads"); + } finally { + if (previous === undefined) delete process.env.LEADBAY_MOCK; + else process.env.LEADBAY_MOCK = previous; + } + }); + + it("returns null for a real submit so the normal poll proceeds", () => { + expect( + mockedSubmitPreview({ job_id: JOB_ID }, "leadbay_find_new_leads", "us") + ).toBeNull(); + }); +}); + +describe("rejectCountryLocations — scalar input", () => { + // The server does not validate the schema before dispatch, so a bare string + // reaches the tool. Treating a non-array as "nothing to check" let a scalar + // country label through to the silent same-named-town fencing. + it("rejects a bare string country label", () => { + expect(() => rejectCountryLocations("United States")).toThrow( + expect.objectContaining({ code: "COUNTRY_LEVEL_LOCATION" }) + ); + expect(() => rejectCountryLocations("la France")).toThrow( + expect.objectContaining({ code: "COUNTRY_LEVEL_LOCATION" }) + ); + }); + + it("still allows a bare string city", () => { + expect(() => rejectCountryLocations("Austin")).not.toThrow(); + expect(() => rejectCountryLocations("Île-de-France")).not.toThrow(); + }); + + it("ignores null/undefined", () => { + expect(() => rejectCountryLocations(null)).not.toThrow(); + expect(() => rejectCountryLocations(undefined)).not.toThrow(); + }); +}); + +describe("mockedSubmitPreview — only in mock mode", () => { + it("throws on a real submit that carried no job_id", () => { + const previous = process.env.LEADBAY_MOCK; + delete process.env.LEADBAY_MOCK; + try { + expect(() => mockedSubmitPreview({}, "leadbay_find_new_leads", "us")).toThrow( + expect.objectContaining({ code: "MALFORMED_SUBMIT_RESPONSE" }) + ); + } finally { + if (previous !== undefined) process.env.LEADBAY_MOCK = previous; + } + }); + + it("returns the preview when mock mode is on", () => { + const previous = process.env.LEADBAY_MOCK; + process.env.LEADBAY_MOCK = "1"; + try { + const out = mockedSubmitPreview({ mocked: true }, "leadbay_find_new_leads", "us"); + expect(out?.submitted).toBe(false); + } finally { + if (previous === undefined) delete process.env.LEADBAY_MOCK; + else process.env.LEADBAY_MOCK = previous; + } + }); +}); + +describe("normalizeSearchFilters — scalar tolerance", () => { + // The server does not validate inputSchema before dispatch, so a bare string + // reaches the tool and would otherwise be POSTed where an array is expected. + it("wraps a scalar sector/location in a one-item array", () => { + const out = normalizeSearchFilters({ locations: "Dallas", sectors: "fitness" })!; + expect(out.locations).toEqual(["Dallas"]); + expect(out.sectors).toEqual(["fitness"]); + }); + + it("leaves arrays untouched", () => { + const out = normalizeSearchFilters({ locations: ["Dallas", "Austin"] })!; + expect(out.locations).toEqual(["Dallas", "Austin"]); + }); + + it("drops a blank scalar rather than sending an empty string", () => { + expect(normalizeSearchFilters({ locations: " " })!.locations).toBeUndefined(); + }); +}); + +describe("set canonicalizers — scalar tolerance", () => { + it("does not throw on a scalar set field", () => { + // A bare `channels: "email"` used to TypeError while DERIVING the key, + // i.e. before the caller ever saw a quote. + expect(canonicalSet("email")).toEqual(["email"]); + expect(canonicalLabelSet("Owner ")).toEqual(["owner"]); + expect(canonicalIdSet("AAAA1111-2222-3333-4444-555566667777")).toEqual([ + "aaaa1111-2222-3333-4444-555566667777", + ]); + }); + + it("folds UUID casing but leaves other ids alone", () => { + expect(canonicalIdSet(["AAAA1111-2222-3333-4444-555566667777"])).toEqual( + canonicalIdSet(["aaaa1111-2222-3333-4444-555566667777"]) + ); + expect(normalizeUuid("Ref-ABC")).toBe("Ref-ABC"); + }); +}); diff --git a/packages/core/test/unit/composite/mcp-key-defaults.test.ts b/packages/core/test/unit/composite/mcp-key-defaults.test.ts new file mode 100644 index 00000000..4652f586 --- /dev/null +++ b/packages/core/test/unit/composite/mcp-key-defaults.test.ts @@ -0,0 +1,113 @@ +/** + * Idempotency-key canonicalization edge cases on the MCP-first delivery tools. + * + * Both are ways a retry of the SAME approved work derives a DIFFERENT key and + * so escapes backend dedupe into a second paid, novelty-claiming job: + * + * 1. `exploration_cap` omitted vs. passed as its documented default min(3n,150). + * Every other defaulted field (min_ai_score, novelty, title_gate) is already + * canonicalized to the value the backend will apply; this one was not. + * 2. A blank `request_id`. It is schema-`required` on the search, but args are + * not validated before dispatch, so `""` reached `??` as an "explicit" value + * and shipped as the key. If the backend reads blank as absent a retry + * double-spends; if it reads blank as a key, unrelated approvals dedupe onto + * each other. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { findNewLeads } from "../../../src/composite/find-new-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +beforeEach(() => resetHttpMock()); + +/** Confirmed PAID search — the only path that derives a key. */ +async function searchBody(extra: Record): Promise { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/search", + status: 200, + body: { job_id: "job-1", state: "queued", items: [] }, + }, + { + method: "GET", + path: /^\/1\.6\/mcp\/jobs\//, + status: 200, + body: { + job: { id: "job-1", state: "succeeded" }, + funnel: { delivered: 0, examined: 0 }, + items: [], + }, + }, + ]); + await findNewLeads.execute(newClient(), { + example_lead: { description: "independent gym, 2 sites" }, + count: 10, + qualify: true, + confirm: true, + wait_seconds: 0, + ...extra, + } as any); + const post = getHttpRequests().find( + (r) => r.method === "POST" && r.path.endsWith("/mcp/search") + ); + return JSON.parse(post!.body ?? "{}"); +} + +describe("leadbay_find_new_leads — key canonicalization", () => { + it("an omitted exploration_cap keys the same as its documented default", async () => { + // count: 10 → min(3*10, 150) = 30 + const omitted = await searchBody({}); + const explicit = await searchBody({ exploration_cap: 30 }); + expect(omitted.request_id).toBeTruthy(); + expect(omitted.request_id).toEqual(explicit.request_id); + }); + + it("an explicit NON-default exploration_cap still keys distinctly", async () => { + const omitted = await searchBody({}); + const raised = await searchBody({ exploration_cap: 120 }); + expect(omitted.request_id).not.toEqual(raised.request_id); + }); + + it("the default tracks count rather than being a fixed number", async () => { + // count: 40 → min(120, 150) = 120, so 120 must match the omitted key here + // while 30 (the count:10 default) must not. + const omitted = await searchBody({ count: 40 }); + const matching = await searchBody({ count: 40, exploration_cap: 120 }); + const other = await searchBody({ count: 40, exploration_cap: 30 }); + expect(omitted.request_id).toEqual(matching.request_id); + expect(omitted.request_id).not.toEqual(other.request_id); + }); + + it("the cap is capped at 150 for large counts", async () => { + // count: 50 → min(150, 150) = 150 + const omitted = await searchBody({ count: 50 }); + const explicit = await searchBody({ count: 50, exploration_cap: 150 }); + expect(omitted.request_id).toEqual(explicit.request_id); + }); + + it("a blank request_id falls back to the derived key", async () => { + const blank = await searchBody({ request_id: " " }); + const missing = await searchBody({}); + expect(blank.request_id).toBeTruthy(); + expect(blank.request_id.trim()).not.toEqual(""); + expect(blank.request_id).toEqual(missing.request_id); + }); + + it("a real request_id is still honoured, trimmed", async () => { + const keyed = await searchBody({ request_id: " gyms-texas-2026-07-28 " }); + expect(keyed.request_id).toEqual("gyms-texas-2026-07-28"); + }); +}); diff --git a/packages/core/test/unit/composite/paid-submit-presend-cancel.test.ts b/packages/core/test/unit/composite/paid-submit-presend-cancel.test.ts new file mode 100644 index 00000000..65d7a536 --- /dev/null +++ b/packages/core/test/unit/composite/paid-submit-presend-cancel.test.ts @@ -0,0 +1,128 @@ +/** + * A paid submit is cancellable up to the moment of dispatch — and not after. + * + * The two halves are a single rule: cancellation is honoured exactly as long as + * we can still PROVE nothing was spent. Queued behind the client's concurrency + * slots, that proof holds. On the wire it does not: the backend may already + * have committed the job, charged for it and claimed novelty on the leads, and + * a torn-down socket cannot tell us which. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +const sent: string[] = []; +// Every dispatched request parks here until the test releases it. Nothing +// completes on its own — a double that auto-answers can never produce the +// "all slots busy, this one is queued" state these tests are about. +const pending: Array<() => void> = []; + +vi.mock("node:https", () => ({ + default: { + request: (options: Record, cb?: (res: unknown) => void) => { + const req = new EventEmitter() as EventEmitter & { + write: () => void; + end: () => void; + destroy: () => void; + }; + req.write = () => {}; + req.destroy = () => {}; + req.end = () => { + // Record at DISPATCH: this array is the record of what actually left + // the process, which is the only thing that can cost money. + sent.push(`${options.method} ${options.path}`); + const finish = () => { + const res = new EventEmitter() as EventEmitter & { + statusCode: number; + headers: Record; + }; + res.statusCode = 200; + res.headers = {}; + cb?.(res); + res.emit("data", Buffer.from(JSON.stringify({ job_id: "job-1", state: "queued" }))); + res.emit("end"); + }; + pending.push(finish); + }; + return req; + }, + }, +})); + +import { LeadbayClient } from "../../../src/client.js"; + +const newClient = () => new LeadbayClient("https://api-us.leadbay.app", "u.tok", "us"); +const settled = () => new Promise((r) => setImmediate(r)); + +beforeEach(() => { + sent.length = 0; + pending.length = 0; +}); + +const flush = () => { + const queued = pending.splice(0); + queued.forEach((f) => f()); +}; + +describe("preSendSignal — the pre-dispatch cancellation window", () => { + it("does not send a submit whose signal is already aborted", async () => { + const ac = new AbortController(); + ac.abort(); + await expect( + newClient().request("POST", "/mcp/search", { q: 1 }, { preSendSignal: ac.signal }) + ).rejects.toMatchObject({ code: "REQUEST_CANCELLED" }); + expect(sent).toHaveLength(0); + }); + + it("cancels a submit still QUEUED behind busy slots, spending nothing", async () => { + const client = newClient(); + // Occupy all five slots with requests that never answer. + const stalled = Array.from({ length: 5 }, () => + client.request("GET", "/stalled").catch(() => {}) + ); + await settled(); + sent.length = 0; + + const ac = new AbortController(); + const submit = client.request("POST", "/mcp/search", { q: 1 }, { + preSendSignal: ac.signal, + }); + await settled(); + ac.abort(); + + await expect(submit).rejects.toMatchObject({ code: "REQUEST_CANCELLED" }); + // The whole point: the POST never left the process, so it cannot have + // charged, and it did not have to wait for the stalled traffic either. + expect(sent.filter((s) => s.includes("/mcp/search"))).toHaveLength(0); + void stalled; + }); + + it("lets an IN-FLIGHT submit finish rather than leaving the spend ambiguous", async () => { + const client = newClient(); + const ac = new AbortController(); + const submit = client.request<{ job_id: string }>( + "POST", + "/mcp/search", + { q: 1 }, + { preSendSignal: ac.signal } + ); + await settled(); + // Dispatched — past the provable-no-spend boundary. + expect(sent.filter((s) => s.includes("/mcp/search"))).toHaveLength(1); + + ac.abort(); + flush(); + + // It resolves normally: the job_id survives, so a job that may have been + // paid for is never orphaned by a late cancel. + await expect(submit).resolves.toMatchObject({ job_id: "job-1" }); + }); + + it("keeps full `signal` cancellation available for reads", async () => { + const ac = new AbortController(); + ac.abort(); + await expect( + newClient().request("GET", "/mcp/jobs/x", undefined, { signal: ac.signal }) + ).rejects.toMatchObject({ code: "REQUEST_CANCELLED" }); + expect(sent).toHaveLength(0); + }); +}); diff --git a/packages/core/test/unit/composite/post-submit-recovery.test.ts b/packages/core/test/unit/composite/post-submit-recovery.test.ts new file mode 100644 index 00000000..af09d875 --- /dev/null +++ b/packages/core/test/unit/composite/post-submit-recovery.test.ts @@ -0,0 +1,151 @@ +/** + * Two ways a paid job could be stranded, and the exhausted-budget overrun. + * + * 1. Once the submit returns, a backend-owned job EXISTS and may be spending. + * Any later failure must still hand back job_id — it is the only route to + * what the user just bought. Previously only a TIMEOUT on the block-waiting + * branch preserved it; an abort, a reset or a 5xx propagated bare, and the + * zero-wait branch preserved nothing at all. + * 2. Truncation means rows were paid for but not read. The rendering rule tells + * the agent to fetch them with `since: next_since` — which the response has + * to actually contain, including on a job that has already finished. + */ +import { describe, it, expect, vi } from "vitest"; +import type { LeadbayClient } from "../../../src/client.js"; +import { + waitForJob, + collectJobSnapshot, + snapshotAfterSubmit, + MCP_JOB_POLL, +} from "../../../src/composite/_mcp-job-helpers.js"; + +const snap = (state: string, extra: Record = {}) => ({ + job: { state }, + items: [], + funnel: {}, + cost: { spent: 0 }, + next_since: null, + ...extra, +}); + +const clientThatThrows = (e: unknown) => + ({ request: async () => { throw e; } }) as unknown as LeadbayClient; + +const coded = (code: string, message: string) => { + const e = new Error(message) as Error & { code?: string }; + e.code = code; + return e; +}; + +describe("post-submit failures keep the job handle", () => { + it.each([ + ["a connection reset", coded("ECONNRESET", "socket hang up")], + ["a backend 5xx", { error: true, code: "UPSTREAM_ERROR", message: "502" }], + ["a cancellation", { error: true, code: "REQUEST_CANCELLED", message: "gone" }], + ["a bare error", new Error("something else")], + ])("attaches job_id when the wait branch hits %s", async (_label, err) => { + const e = await snapshotAfterSubmit(clientThatThrows(err), "job-42", 5).catch((x) => x); + expect(e.job_id).toBe("job-42"); + expect(e.hint).toContain("leadbay_lead_job_status"); + }); + + it("attaches job_id on the ZERO-WAIT branch too", async () => { + // This branch previously propagated every failure bare. + const e = await snapshotAfterSubmit( + clientThatThrows(coded("ECONNRESET", "socket hang up")), + "job-42", + 0 + ).catch((x) => x); + expect(e.job_id).toBe("job-42"); + }); + + it("does not double-wrap an error that already carries the handle", async () => { + const e = await snapshotAfterSubmit( + clientThatThrows(coded("TIMEOUT", "slow")), + "job-42", + 1 + ).catch((x) => x); + expect(e.code).toBe("JOB_READ_TIMEOUT"); + expect(e.job_id).toBe("job-42"); + expect(e.message).not.toContain("reading its status failed"); + }); + + it("passes a successful snapshot straight through", async () => { + const client = { request: async () => snap("completed") } as unknown as LeadbayClient; + await expect(snapshotAfterSubmit(client, "job-42", 0)).resolves.toMatchObject({ + job: { state: "completed" }, + }); + }); +}); + +describe("an exhausted wait budget stops the poll", () => { + const REAL = MCP_JOB_POLL.intervalMs; + + it("does not grant a final snapshot a budget the caller no longer has", async () => { + MCP_JOB_POLL.intervalMs = 5; + let now = 0; + const spy = vi.spyOn(Date, "now").mockImplementation(() => now); + const opts: Array<{ totalTimeoutMs?: number }> = []; + try { + const client = { + request: async (_m: string, _p: string, _b: unknown, o?: { totalTimeoutMs?: number }) => { + opts.push(o ?? {}); + now += 400; // every read eats most of a 1s budget + return snap("running"); + }, + } as unknown as LeadbayClient; + + await waitForJob(client, "job-1", 1); + // The old 1s FLOOR let a final poll run a full second past an already + // spent budget, so `wait_seconds: 1` took ~2s. Asserting "<= 1000" cannot + // catch that — the floor IS 1000. Each budget must match what is actually + // left, so they strictly decrease. + expect(opts.length).toBeGreaterThan(1); + const budgets = opts.map((o) => o.totalTimeoutMs!); + for (let i = 1; i < budgets.length; i++) { + expect(budgets[i]).toBeLessThan(budgets[i - 1]); + } + // 400ms consumed per read out of 1000 → the last one gets ~200, not 1000. + expect(budgets[budgets.length - 1]).toBeLessThanOrEqual(200); + } finally { + spy.mockRestore(); + MCP_JOB_POLL.intervalMs = REAL; + } + }); + + it("does not round a fractional wait up on the first snapshot", async () => { + const opts: Array<{ totalTimeoutMs?: number }> = []; + const client = { + request: async (_m: string, _p: string, _b: unknown, o?: { totalTimeoutMs?: number }) => { + opts.push(o ?? {}); + return snap("completed"); + }, + } as unknown as LeadbayClient; + + await waitForJob(client, "job-1", 0.5); + // A 1s floor turned a 500ms wait into a 1s one. + expect(opts[0].totalTimeoutMs!).toBeLessThanOrEqual(500); + }); +}); + +describe("a truncated drain hands back a usable cursor", () => { + it("keeps next_since on the snapshot when the drain stops early", async () => { + let call = 0; + const client = { + request: async () => { + call++; + // Full page + cursor = more to come. + return { + ...snap("completed"), + items: [{ id: "a" }, { id: "b" }], + next_since: `cur-${call}`, + }; + }, + } as unknown as LeadbayClient; + + const s = await collectJobSnapshot(client, "job-1", undefined, 2, undefined, 1); + expect(s.items_truncated).toBe(true); + // The rendering rule names this cursor; it has to exist. + expect(s.next_since).toBeTruthy(); + }); +}); diff --git a/packages/core/test/unit/composite/qualify-input-indexes.test.ts b/packages/core/test/unit/composite/qualify-input-indexes.test.ts new file mode 100644 index 00000000..c5db046b --- /dev/null +++ b/packages/core/test/unit/composite/qualify-input-indexes.test.ts @@ -0,0 +1,90 @@ +/** + * `ref.input_indexes` must describe THIS caller's `lead_refs`, or say nothing. + * + * The qualify idempotency key is order-insensitive on purpose — the same refs + * in another order are the same approved work — so a reordered retry dedupes + * onto the ORIGINAL job. That job's `input_indexes` describe the ORIGINAL + * order, and relaying them maps each skipped-item verdict onto the wrong + * company: retry `[B, A]` after `[A, B]` and A is reported at index 0. + * + * Remap where every item resolves by ref identity; null the indexes for ALL + * items where any one cannot be matched. A missing index is a gap; a wrong + * index is a false statement about which company was skipped. + */ + +import { describe, it, expect } from "vitest"; +import { remapInputIndexes } from "../../../src/composite/_mcp-job-helpers.js"; + +const item = (requested_as: unknown, input_indexes: number[] | null) => + ({ + status: "skipped", + seq: 1, + ref: { input_indexes, requested_as }, + }) as any; + +describe("remapInputIndexes", () => { + it("re-points indexes at the current order after a reordered retry", () => { + // Original job ran [A, B]; this caller sent [B, A]. + const items = [ + item({ website: "a.com" }, [0]), + item({ website: "b.com" }, [1]), + ]; + const { items: out, remapped } = remapInputIndexes(items, [ + { website: "b.com" }, + { website: "a.com" }, + ]); + expect(remapped).toBe(true); + expect(out[0].ref.input_indexes).toEqual([1]); // a.com is now index 1 + expect(out[1].ref.input_indexes).toEqual([0]); // b.com is now index 0 + }); + + it("folds website spelling the same way the key does", () => { + // The caller pasted a URL; the backend echoed the bare domain. + const items = [item({ website: "acme.com" }, [0])]; + const { items: out, remapped } = remapInputIndexes(items, [ + { name: "Other" }, + { website: "https://Acme.com/" }, + ]); + expect(remapped).toBe(true); + expect(out[0].ref.input_indexes).toEqual([1]); + }); + + it("matches on lead_id when requested_as is absent", () => { + const id = "7b3c1de2-5f40-4a9c-9d21-0c8ea4f61b55"; + const items = [{ status: "skipped", seq: 1, ref: { input_indexes: [0], lead_id: id } } as any]; + const { items: out, remapped } = remapInputIndexes(items, [ + { name: "Other" }, + { lead_id: id.toUpperCase() }, + ]); + expect(remapped).toBe(true); + expect(out[0].ref.input_indexes).toEqual([1]); + }); + + it("nulls EVERY index when any item cannot be matched", () => { + // Partial remapping would leave a mix of correct and stale indexes with + // no way for the caller to tell them apart. + const items = [ + item({ website: "a.com" }, [0]), + item({ website: "unknown.com" }, [1]), + ]; + const { items: out, remapped } = remapInputIndexes(items, [ + { website: "a.com" }, + ]); + expect(remapped).toBe(false); + expect(out[0].ref.input_indexes).toBeNull(); + expect(out[1].ref.input_indexes).toBeNull(); + }); + + it("leaves items that carry no indexes alone", () => { + const items = [item({ website: "a.com" }, null)]; + const { items: out } = remapInputIndexes(items, [{ website: "a.com" }]); + expect(out[0].ref.input_indexes).toBeNull(); + }); + + it("no refs to map against is a no-op, not a wipe", () => { + const items = [item({ website: "a.com" }, [0])]; + const { items: out, remapped } = remapInputIndexes(items, undefined); + expect(remapped).toBe(false); + expect(out[0].ref.input_indexes).toEqual([0]); + }); +}); diff --git a/packages/core/test/unit/composite/qualify-leads-idempotency.test.ts b/packages/core/test/unit/composite/qualify-leads-idempotency.test.ts new file mode 100644 index 00000000..b644e5e0 --- /dev/null +++ b/packages/core/test/unit/composite/qualify-leads-idempotency.test.ts @@ -0,0 +1,284 @@ +/** + * Derived idempotency key for a consented paid qualify batch. + * + * `request_id` is optional on this tool, so a paid submit without one could be + * re-run by any timeout or agent retry and re-charge qualification + channel + * purchases for the same refs. The derived key must therefore be a function of + * the APPROVED BATCH and nothing else — in particular not of the clock, so a + * retry that lands after midnight still dedupes. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { qualifyLeads } from "../../../src/composite/qualify-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); +const JOB_ID = "b1f0e7c4-2a56-4d80-9c33-5e6f1a2b3c4d"; + +const SUBMIT_202 = { + job_id: JOB_ID, + status_url: `/1.6/mcp/jobs/${JOB_ID}`, + estimated_cost: { max: 238, unit: "cost_cents" }, + items_requested: 2, + duplicate: false, +}; + +const SNAPSHOT = { + job: { id: JOB_ID, state: "completed" }, + funnel: { delivered: 1 }, + items: [], + cost: { spent: 0, unit: "cost_cents", breakdown: {} }, + next_since: null, + explain: { region: "us", model: "m" }, +}; + +beforeEach(() => resetHttpMock()); + +async function submittedRequestId(params: Record) { + resetHttpMock(); + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: SNAPSHOT, + }, + ]); + await qualifyLeads.execute(newClient(), { + confirm: true, + wait_seconds: 0, + ...params, + } as any); + const post = getHttpRequests().find((r: any) => r.method === "POST")!; + return JSON.parse(post.body!).request_id as string; +} + +const REFS = [{ website: "a.com" }, { website: "b.com" }]; + +describe("qualify_leads — derived idempotency key", () => { + it("sends a derived request_id when a paid call omits one", async () => { + const id = await submittedRequestId({ lead_refs: REFS }); + // 128-bit digest: a 32-bit one collided in practice across distinct batches. + expect(id).toMatch(/^qualify-auto-[0-9a-f]{32}$/); + }); + + it("is stable across identical retries", async () => { + const a = await submittedRequestId({ lead_refs: REFS }); + const b = await submittedRequestId({ lead_refs: REFS }); + expect(a).toBe(b); + }); + + it("does not depend on the clock (a retry after midnight still dedupes)", async () => { + const a = await submittedRequestId({ lead_refs: REFS }); + // Shift the wall clock past a UTC midnight WITHOUT faking timers — fake + // timers would stall the awaited HTTP mock. Stubbing Date.now + the Date + // constructor is enough to catch any date component in the hash. + const RealDate = Date; + const shifted = new RealDate("2031-03-04T00:00:01Z").getTime(); + // @ts-expect-error — deliberate narrow stub for this assertion + globalThis.Date = class extends RealDate { + constructor(...args: any[]) { + // @ts-expect-error — passthrough + super(...(args.length ? args : [shifted])); + } + static now() { + return shifted; + } + }; + try { + const b = await submittedRequestId({ lead_refs: REFS }); + expect(b).toBe(a); + } finally { + globalThis.Date = RealDate; + } + }); + + it("ref order does not change the key", async () => { + const a = await submittedRequestId({ lead_refs: REFS }); + const b = await submittedRequestId({ lead_refs: [...REFS].reverse() }); + expect(a).toBe(b); + }); + + it("a raised max_cost is a NEW approved run", async () => { + const capped = await submittedRequestId({ lead_refs: REFS, max_cost: 500 }); + const raised = await submittedRequestId({ lead_refs: REFS, max_cost: 5000 }); + expect(raised).not.toBe(capped); + }); + + it("different channels key differently", async () => { + const email = await submittedRequestId({ lead_refs: REFS, channels: ["email"] }); + const phone = await submittedRequestId({ lead_refs: REFS, channels: ["phone"] }); + expect(email).not.toBe(phone); + }); + + it("different prior_deliveries slices key differently", async () => { + const first = await submittedRequestId({ + prior_deliveries: { job_id: "j1", limit: 50 }, + }); + const next = await submittedRequestId({ + prior_deliveries: { job_id: "j1", limit: 50, since: "cursor-50" }, + }); + expect(next).not.toBe(first); + }); + + it("a different output language keys differently", async () => { + const en = await submittedRequestId({ lead_refs: REFS, lang: "en" }); + const fr = await submittedRequestId({ lead_refs: REFS, lang: "fr" }); + expect(fr).not.toBe(en); + }); + + it("the same value under a different ref field keys differently", async () => { + // {website:"acme.com"} and {name:"acme.com"} resolve differently backend + // side, so they must not collapse onto one key. + const byWebsite = await submittedRequestId({ + lead_refs: [{ website: "acme.com" }], + }); + const byName = await submittedRequestId({ lead_refs: [{ name: "acme.com" }] }); + expect(byName).not.toBe(byWebsite); + }); + + it("does not collide on the pair that broke the 32-bit digest", async () => { + // Both of these hashed to qualify-auto-76d7841e under FNV-1a, which would + // have deduped one paid approval onto the other's job. + const a = await submittedRequestId({ + lead_refs: [{ website: "aeqexh0jh0.com" }], + }); + const b = await submittedRequestId({ + lead_refs: [{ website: "99rcha4ssn.com" }], + }); + expect(a).not.toBe(b); + }); + + it("a value containing the old delimiters cannot forge another ref", async () => { + // Under the delimiter-joined shape these serialized identically: + // {website:"acme~name=Paris"} == {website:"acme", name:"Paris~name="} + const forged = await submittedRequestId({ + lead_refs: [{ website: "acme~name=Paris" }], + }); + const genuine = await submittedRequestId({ + lead_refs: [{ website: "acme", name: "Paris~name=" }], + }); + expect(forged).not.toBe(genuine); + }); + + it("duplicate refs do not fork the key", async () => { + // The backend collapses duplicate refs into one item, so these are the + // same approved work. A retry that happened to dedupe would otherwise + // present a new key and re-run the whole paid batch. + const once = await submittedRequestId({ lead_refs: [{ website: "acme.com" }] }); + const twice = await submittedRequestId({ + lead_refs: [{ website: "acme.com" }, { website: "acme.com" }], + }); + expect(twice).toBe(once); + }); + + it("an explicit request_id always wins", async () => { + const id = await submittedRequestId({ lead_refs: REFS, request_id: "mine-1" }); + expect(id).toBe("mine-1"); + }); +}); + +describe("qualify_leads — ref + set normalization", () => { + it("a pasted URL and its normalized domain share a key", async () => { + const pasted = await submittedRequestId({ + lead_refs: [{ website: "https://Acme.com/" }], + }); + const clean = await submittedRequestId({ lead_refs: [{ website: "acme.com" }] }); + expect(clean).toBe(pasted); + }); + + it("stray whitespace and casing do not fork the key", async () => { + const messy = await submittedRequestId({ + lead_refs: [{ name: " Franklin Barbecue ", location: " Austin " }], + }); + const tidy = await submittedRequestId({ + lead_refs: [{ name: "franklin barbecue", location: "austin" }], + }); + expect(tidy).toBe(messy); + }); + + it("a genuinely different domain still forks the key", async () => { + const a = await submittedRequestId({ lead_refs: [{ website: "acme.com" }] }); + const b = await submittedRequestId({ lead_refs: [{ website: "other.com" }] }); + expect(b).not.toBe(a); + }); + + it("duplicate channels do not fork the key", async () => { + const once = await submittedRequestId({ + lead_refs: [{ website: "acme.com" }], + channels: ["email"], + }); + const twice = await submittedRequestId({ + lead_refs: [{ website: "acme.com" }], + channels: ["email", "email"], + }); + expect(twice).toBe(once); + }); +}); + +describe("qualify_leads — label normalization", () => { + it("contact_titles casing and whitespace do not fork the key", async () => { + const a = await submittedRequestId({ + lead_refs: [{ website: "acme.com" }], + contact_titles: ["Owner"], + }); + const b = await submittedRequestId({ + lead_refs: [{ website: "acme.com" }], + contact_titles: ["owner "], + }); + expect(b).toBe(a); + }); + + it("a genuinely different title still forks the key", async () => { + const owner = await submittedRequestId({ + lead_refs: [{ website: "acme.com" }], + contact_titles: ["Owner"], + }); + const cto = await submittedRequestId({ + lead_refs: [{ website: "acme.com" }], + contact_titles: ["CTO"], + }); + expect(cto).not.toBe(owner); + }); +}); + +describe("qualify_leads — UUID casing", () => { + it("an uppercase lead_id shares a key with its lowercase form", async () => { + const upper = await submittedRequestId({ + lead_refs: [{ lead_id: "AAAA1111-2222-3333-4444-555566667777" }], + }); + const lower = await submittedRequestId({ + lead_refs: [{ lead_id: "aaaa1111-2222-3333-4444-555566667777" }], + }); + expect(lower).toBe(upper); + }); + + it("a non-UUID id keeps its casing (backend may be case-sensitive)", async () => { + const a = await submittedRequestId({ lead_refs: [{ lead_id: "Ref-ABC" }] }); + const b = await submittedRequestId({ lead_refs: [{ lead_id: "ref-abc" }] }); + expect(b).not.toBe(a); + }); +}); + +describe("qualify_leads — prior_deliveries UUID casing", () => { + it("job_id casing does not fork the key", async () => { + const upper = await submittedRequestId({ + prior_deliveries: { job_id: "0A2FCBF5-18E1-4967-B5DE-0C67CD823BCC" }, + }); + const lower = await submittedRequestId({ + prior_deliveries: { job_id: "0a2fcbf5-18e1-4967-b5de-0c67cd823bcc" }, + }); + expect(lower).toBe(upper); + }); +}); diff --git a/packages/core/test/unit/composite/qualify-leads-spend-gate.test.ts b/packages/core/test/unit/composite/qualify-leads-spend-gate.test.ts new file mode 100644 index 00000000..bdd19a38 --- /dev/null +++ b/packages/core/test/unit/composite/qualify-leads-spend-gate.test.ts @@ -0,0 +1,293 @@ +/** + * Spend gate + render envelope for leadbay_qualify_leads. + * + * `qualify` defaults to TRUE on the backend (~94 cost_cents per lead needing + * fresh research), so a bare call carrying only `lead_refs` used to be a PAID + * submit of up to 500 refs that the user never approved. The consent gate has + * to live in code — description prose does not stop an agent that skips it. + * + * These tests pin the withhold, the veto, the free pass-through, and the + * `{leads, skipped}` envelope the shared rendering contract mandates. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { qualifyLeads } from "../../../src/composite/qualify-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +const JOB_ID = "7b3c1de2-5f40-4a9c-9d21-0c8ea4f61b55"; + +const REFS = [{ website: "franklinbbq.com" }, { website: "uchiaustin.com" }]; + +const DRY_RUN_200 = { + valid: true, + items_requested: 2, + estimated_cost: { max: 238, unit: "cost_cents" }, + quota_forecast: { + web_fetch_allowed: true, + rescore_allowed: true, + enrichment_allowed: true, + }, +}; + +const SUBMIT_202 = { + job_id: JOB_ID, + status_url: `/1.6/mcp/jobs/${JOB_ID}`, + estimated_cost: { max: 238, unit: "cost_cents" }, + items_requested: 2, + duplicate: false, +}; + +const DELIVERED_ITEM = { + ref: { input_indexes: [0], requested_as: { website: "franklinbbq.com" } }, + status: "delivered", + seq: 0, + cost: { billed: 94, unit: "cost_cents" }, + lead: { + lead_id: "aaaa1111-2222-3333-4444-555566667777", + company: { name: "Franklin Barbecue" }, + }, +}; + +const SKIPPED_ITEM = { + ref: { input_indexes: [1], requested_as: { website: "uchiaustin.com" } }, + status: "skipped", + seq: 1, + status_reason: "not_in_universe", +}; + +const TERMINAL_SNAPSHOT = { + job: { job_id: JOB_ID, state: "completed" }, + funnel: { delivered: 1, examined: 2 }, + items: [DELIVERED_ITEM, SKIPPED_ITEM], + cost: { spent: 94, unit: "cost_cents" }, + next_since: null, +}; + +beforeEach(() => resetHttpMock()); + +const postBodies = () => + getHttpRequests() + .filter((r: any) => r.method === "POST") + .map((r: any) => (typeof r.body === "string" ? JSON.parse(r.body) : r.body)); + +describe("leadbay_qualify_leads — spend gate", () => { + it("withholds the paid submit when confirm is absent, and quotes instead", async () => { + // Only the free dry_run may be called — never the submit. + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 200, body: DRY_RUN_200 }, + ]); + + const result: any = await qualifyLeads.execute(newClient(), { + lead_refs: REFS, + }); + + expect(result.mode).toBe("needs_confirmation"); + expect(result.submitted).toBe(false); + expect(result.vetoed).toBe(false); + expect(result.job_id).toBeUndefined(); + expect(result.estimated_cost).toEqual({ max: 238, unit: "cost_cents" }); + + // Exactly one POST, and it was the FREE dry run. + const posts = postBodies(); + expect(posts).toHaveLength(1); + expect(posts[0].dry_run).toBe(true); + }); + + it("names why the call was treated as paid (backend default is true)", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 200, body: DRY_RUN_200 }, + ]); + + const result: any = await qualifyLeads.execute(newClient(), { + lead_refs: REFS, + }); + + expect(result.paid_because.join(" ")).toMatch(/qualify is on/); + }); + + it("treats requested channels as paid even when qualify is off", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 200, body: DRY_RUN_200 }, + ]); + + const result: any = await qualifyLeads.execute(newClient(), { + lead_refs: REFS, + qualify: false, + channels: ["email"], + }); + + expect(result.mode).toBe("needs_confirmation"); + expect(result.paid_because.join(" ")).toMatch(/channels requested: email/); + }); + + it("confirm:false is a veto — no submit AND no quote round-trip", async () => { + // No endpoints declared: the harness throws if ANY request is made. + mockHttp([]); + + const result: any = await qualifyLeads.execute(newClient(), { + lead_refs: REFS, + confirm: false, + }); + + expect(result.mode).toBe("needs_confirmation"); + expect(result.vetoed).toBe(true); + expect(result.quote).toBeNull(); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("a fully free call (qualify:false, no channels) passes straight through", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: TERMINAL_SNAPSHOT, + }, + ]); + + const result: any = await qualifyLeads.execute(newClient(), { + lead_refs: REFS, + qualify: false, + wait_seconds: 0, + }); + + expect(result.mode).toBeUndefined(); + expect(result.job_id).toBe(JOB_ID); + + // The submit went out and was NOT a dry run. + const posts = postBodies(); + expect(posts).toHaveLength(1); + expect(posts[0].dry_run).toBeUndefined(); + expect(posts[0].qualify).toBe(false); + }); + + it("confirm:true submits the paid job without a forced extra quote", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: TERMINAL_SNAPSHOT, + }, + ]); + + const result: any = await qualifyLeads.execute(newClient(), { + lead_refs: REFS, + confirm: true, + wait_seconds: 0, + }); + + expect(result.job_id).toBe(JOB_ID); + expect(result.done).toBe(true); + + const posts = postBodies(); + expect(posts).toHaveLength(1); + expect(posts[0].dry_run).toBeUndefined(); + // `confirm` is a client-side gate — it must not leak onto the wire. + expect(posts[0].confirm).toBeUndefined(); + }); + + it("an explicit dry_run still quotes without needing confirm", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 200, body: DRY_RUN_200 }, + ]); + + const result: any = await qualifyLeads.execute(newClient(), { + lead_refs: REFS, + dry_run: true, + }); + + expect(result.dry_run).toBe(true); + expect(result.mode).toBeUndefined(); + expect(result.estimated_cost).toEqual({ max: 238, unit: "cost_cents" }); + }); +}); + +describe("leadbay_qualify_leads — render envelope", () => { + it("returns leads[]/skipped[] alongside items[] on a completed job", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: TERMINAL_SNAPSHOT, + }, + ]); + + const result: any = await qualifyLeads.execute(newClient(), { + lead_refs: REFS, + confirm: true, + wait_seconds: 0, + }); + + // The shared rendering contract reads deliveries from leads[] and + // skips from skipped[]; returning only items[] left both tables empty. + expect(Array.isArray(result.leads)).toBe(true); + expect(Array.isArray(result.skipped)).toBe(true); + expect(result.leads).toHaveLength(1); + expect(result.skipped).toHaveLength(1); + + // items[] is preserved for input-order per-ref mapping. + expect(result.items).toHaveLength(2); + }); +}); + +describe("leadbay_qualify_leads — unvalidated arg shapes", () => { + // The MCP server does not validate inputSchema before dispatch, so an agent + // can send the natural singular form. These used to TypeError BEFORE the + // spend gate, so the caller got a crash instead of the promised quote. + it("a single lead_refs object is treated as a one-item list", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 200, body: DRY_RUN_200 }, + ]); + + const result: any = await qualifyLeads.execute(newClient(), { + lead_refs: { website: "acme.com" }, + } as any); + + expect(result.mode).toBe("needs_confirmation"); + expect(postBodies()[0].lead_refs).toEqual([{ website: "acme.com" }]); + }); + + it("a scalar channels value reaches the wire as an array", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 200, body: DRY_RUN_200 }, + ]); + + const result: any = await qualifyLeads.execute(newClient(), { + lead_refs: REFS, + channels: "email", + } as any); + + expect(result.mode).toBe("needs_confirmation"); + expect(postBodies()[0].channels).toEqual(["email"]); + }); + + it("a scalar contact_titles value reaches the wire as an array", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 200, body: DRY_RUN_200 }, + ]); + + await qualifyLeads.execute(newClient(), { + lead_refs: REFS, + contact_titles: "Owner", + } as any); + + expect(postBodies()[0].contact_titles).toEqual(["Owner"]); + }); +}); diff --git a/packages/core/test/unit/composite/qualify-leads-string-refs.test.ts b/packages/core/test/unit/composite/qualify-leads-string-refs.test.ts new file mode 100644 index 00000000..576ebcfd --- /dev/null +++ b/packages/core/test/unit/composite/qualify-leads-string-refs.test.ts @@ -0,0 +1,144 @@ +/** + * String shorthand in `lead_refs` for leadbay_qualify_leads. + * + * MCP args arrive unvalidated and `coerceArrayParams` wraps a scalar, so + * `lead_refs: "acme.com"` reaches the tool as `["acme.com"]` — a STRING where + * the schema promises an object. Read as an object it yields an all-null ref, + * so every string ref canonicalized identically: two different companies + * derived the SAME `qualify-auto-*` request_id and the second batch would + * dedupe onto the first PAID job. The raw string was also posted as-is, which + * the backend rejects after this tool already promised a quote. + * + * These tests pin the reshape: the submitted body carries the documented + * object shape, and two different companies no longer share a key. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { qualifyLeads } from "../../../src/composite/qualify-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +const LEAD_UUID = "7b3c1de2-5f40-4a9c-9d21-0c8ea4f61b55"; + +const DRY_RUN_200 = { + valid: true, + items_requested: 1, + estimated_cost: { max: 94, unit: "cost_cents" }, +}; + +beforeEach(() => resetHttpMock()); + +/** Drive the FREE path so no consent gate is involved: qualify:false, no + * channels. The submit body is what we assert on. */ +async function submittedBody(lead_refs: unknown): Promise { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/qualify", + status: 200, + body: { job_id: "job-1", state: "queued", items: [] }, + }, + { + method: "GET", + // The poller appends cursor params, so match the route, not an exact path. + path: /^\/1\.6\/mcp\/jobs\//, + status: 200, + body: { + job: { id: "job-1", state: "succeeded" }, + funnel: { delivered: 0, examined: 0 }, + items: [], + }, + }, + ]); + await qualifyLeads.execute(newClient(), { + lead_refs, + qualify: false, + wait_seconds: 0, + } as any); + const post = getHttpRequests().find( + (r) => r.method === "POST" && r.path.endsWith("/mcp/qualify") + ); + return JSON.parse(post!.body ?? "{}"); +} + +/** The PAID path, consented, so a `request_id` is actually derived. */ +async function paidSubmittedBody(lead_refs: unknown): Promise { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/qualify", + status: 200, + body: { job_id: "job-1", state: "queued", items: [] }, + }, + { + method: "GET", + path: /^\/1\.6\/mcp\/jobs\//, + status: 200, + body: { + job: { id: "job-1", state: "succeeded" }, + funnel: { delivered: 0, examined: 0 }, + items: [], + }, + }, + ]); + await qualifyLeads.execute(newClient(), { + lead_refs, + qualify: true, + confirm: true, + wait_seconds: 0, + } as any); + const post = getHttpRequests().find( + (r) => r.method === "POST" && r.path.endsWith("/mcp/qualify") + ); + return JSON.parse(post!.body ?? "{}"); +} + +describe("leadbay_qualify_leads — string lead_refs", () => { + it("a bare domain becomes {website}, not a string the backend 400s on", async () => { + const body = await submittedBody(["acme.com"]); + expect(body.lead_refs).toEqual([{ website: "acme.com" }]); + }); + + it("a UUID becomes {lead_id}", async () => { + const body = await submittedBody([LEAD_UUID]); + expect(body.lead_refs).toEqual([{ lead_id: LEAD_UUID }]); + }); + + it("a non-domain, non-UUID string becomes {name}", async () => { + const body = await submittedBody(["Franklin Barbecue"]); + expect(body.lead_refs).toEqual([{ name: "Franklin Barbecue" }]); + }); + + it("a scalar (not a list) is still reshaped after coercion", async () => { + const body = await submittedBody("acme.com"); + expect(body.lead_refs).toEqual([{ website: "acme.com" }]); + }); + + it("object refs pass through untouched", async () => { + const body = await submittedBody([{ website: "acme.com", name: "Acme" }]); + expect(body.lead_refs).toEqual([{ website: "acme.com", name: "Acme" }]); + }); + + it("two different companies no longer derive the same request_id", async () => { + // The collapse that mattered, and it only bites on the PAID path — that is + // where a key is derived. Read as objects, every string ref produced an + // all-null canonical form, so two unrelated companies hashed to the same + // `qualify-auto-*` key and the second paid batch deduped onto the first job. + const a = await paidSubmittedBody(["acme.com"]); + const b = await paidSubmittedBody(["globex.com"]); + expect(a.request_id).toBeTruthy(); + expect(b.request_id).toBeTruthy(); + expect(a.request_id).not.toEqual(b.request_id); + }); +}); diff --git a/packages/core/test/unit/composite/qualify-leads.test.ts b/packages/core/test/unit/composite/qualify-leads.test.ts new file mode 100644 index 00000000..1be7e753 --- /dev/null +++ b/packages/core/test/unit/composite/qualify-leads.test.ts @@ -0,0 +1,192 @@ +/** + * Unit tests for leadbay_qualify_leads (POST /mcp/qualify + job poll). + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { qualifyLeads } from "../../../src/composite/qualify-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +const JOB_ID = "2900fff9-2985-4220-84dc-70a551fc3e84"; + +const SUBMIT_202 = { + job_id: JOB_ID, + status_url: `/1.6/mcp/jobs/${JOB_ID}`, + estimated_cost: { max: 476, unit: "cost_cents" }, + items_requested: 3, + duplicate: false, +}; + +const QUALIFIED_ITEM = { + ref: { input_indexes: [0], requested_as: { website: "franklinbbq.com" } }, + status: "delivered", + seq: 0, + completed_at: "2026-07-28T10:20:00Z", + cost: { billed: 94, unit: "cost_cents" }, + lead: { + lead_id: "aaaa1111-2222-3333-4444-555566667777", + company: { name: "Franklin Barbecue", employees: { min: 11, max: 50, known: true } }, + fit: { + available: true, + score: 72, + components: { + qualification: { available: true, ai_score: 12, questions: [], matched_tags: [], unmatched_tags: [] }, + }, + }, + web_research: { available: true }, + contact: { + lead_contact_id: "cccc1111-2222-3333-4444-555566667777", + name: "Aaron Franklin", + role: "Owner", + channels: { + email: { status: "delivered", value: "aaron@franklinbbq.com", billed: 25 }, + phone: { status: "not_requested" }, + }, + }, + }, +}; + +const NOT_IN_UNIVERSE_ITEM = { + ref: { input_indexes: [1], requested_as: { name: "Totally Nonexistent Bistro" } }, + status: "skipped", + status_reason: "not_in_universe", + seq: 1, + cost: { billed: 0, unit: "cost_cents" }, +}; + +const SNAPSHOT_DONE = { + job: { + id: JOB_ID, + state: "completed", + submitted_at: "2026-07-28T10:19:00Z", + expires_at: "2026-08-27T10:19:00Z", + last_progress_at: "2026-07-28T10:20:30Z", + }, + funnel: { + matched: 2, + examined: 1, + qualified: 1, + disqualified: 0, + delivered: 1, + delivered_callable: 1, + degraded: 0, + resolved: 1, + not_in_universe: 1, + stop_reason: "pool_exhausted", + }, + items: [QUALIFIED_ITEM, NOT_IN_UNIVERSE_ITEM], + next_since: "1785233900000000:1", + cost: { spent: 119, unit: "cost_cents", breakdown: { enrichment_cents: 25 } }, + explain: { region: "US", model: "text_v2_ai_description", scope_notes: [] }, +}; + +beforeEach(() => resetHttpMock()); + +describe("leadbay_qualify_leads", () => { + it("happy path — per-item verdicts including honest skips", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: SNAPSHOT_DONE, + }, + ]); + const result = await qualifyLeads.execute(newClient(), { + lead_refs: [ + { website: "franklinbbq.com", name: "Franklin Barbecue" }, + { name: "Totally Nonexistent Bistro" }, + ], + contact_titles: ["Owner"], + channels: ["email"], + request_id: "vet-austin-1", + confirm: true, + wait_seconds: 0, + }); + + expect(result.job_id).toBe(JOB_ID); + expect(result.done).toBe(true); + expect(result.items).toHaveLength(2); + const skipped = result.items.find((i: any) => i.status === "skipped"); + expect(skipped.status_reason).toBe("not_in_universe"); + expect(result.summary.not_in_universe).toBe(1); + expect(result.summary.delivered_callable).toBe(1); + const delivered = result.items.find((i: any) => i.status === "delivered"); + expect(delivered.lead.contact.channels.email.value).toBe("aaron@franklinbbq.com"); + + const submit = getHttpRequests().find((r) => r.method === "POST")!; + const body = JSON.parse(submit.body!); + expect(body.lead_refs).toHaveLength(2); + expect(body.contact_titles).toEqual(["Owner"]); + expect(body.wait_seconds).toBeUndefined(); + }); + + it("prior_deliveries selector — passes through without lead_refs", async () => { + mockHttp([ + { method: "POST", path: "/1.6/mcp/qualify", status: 202, body: SUBMIT_202 }, + { + method: "GET", + path: `/1.6/mcp/jobs/${JOB_ID}?limit=100`, + status: 200, + body: SNAPSHOT_DONE, + }, + ]); + await qualifyLeads.execute(newClient(), { + prior_deliveries: { job_id: "0a2fcbf5-18e1-4967-b5de-0c67cd823bcc" }, + wait_seconds: 0, + }); + const submit = getHttpRequests().find((r) => r.method === "POST")!; + const body = JSON.parse(submit.body!); + expect(body.prior_deliveries.job_id).toBe("0a2fcbf5-18e1-4967-b5de-0c67cd823bcc"); + expect(body.lead_refs).toBeUndefined(); + }); + + it("dry_run — forecast only, no job created", async () => { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/qualify", + status: 200, + body: { + valid: true, + items_requested: 2, + estimated_cost: { max: 238, unit: "cost_cents" }, + quota_forecast: { web_fetch_allowed: true, rescore_allowed: true, enrichment_allowed: false }, + }, + }, + ]); + const result = await qualifyLeads.execute(newClient(), { + lead_refs: [{ website: "a.com" }, { website: "b.com" }], + dry_run: true, + }); + expect(result.dry_run).toBe(true); + expect(result.quota_forecast.enrichment_allowed).toBe(false); + expect(getHttpRequests()).toHaveLength(1); + }); + + it("validation 400 (bad ref) — propagates", async () => { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/qualify", + status: 400, + body: { error: "bad_request", message: "lead_refs[0] has no identifying field" }, + }, + ]); + await expect( + qualifyLeads.execute(newClient(), { lead_refs: [{}] }) + ).rejects.toMatchObject({ error: true }); + }); +}); diff --git a/packages/core/test/unit/composite/qualify-malformed-refs.test.ts b/packages/core/test/unit/composite/qualify-malformed-refs.test.ts new file mode 100644 index 00000000..4681178a --- /dev/null +++ b/packages/core/test/unit/composite/qualify-malformed-refs.test.ts @@ -0,0 +1,119 @@ +/** + * Malformed `lead_refs` entries are refused, not crashed on. + * + * MCP args are not schema-validated before dispatch, and `normalizeLeadRefs` + * deliberately passes non-strings through untouched (so object refs survive). + * A `null` therefore reached `derivedRequestId`, whose first property access + * threw a raw TypeError — BEFORE the spend gate could return a quote. The + * caller got a stack trace where the contract promises a named error. + * + * Dropping the bad entries instead would be worse: the batch would qualify and + * BILL a subset of what the user listed without saying so. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { qualifyLeads } from "../../../src/composite/qualify-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +beforeEach(() => resetHttpMock()); + +async function run(lead_refs: unknown, extra: Record = {}) { + mockHttp([]); + return qualifyLeads.execute(newClient(), { + lead_refs, + wait_seconds: 0, + ...extra, + } as any); +} + +async function refusal(lead_refs: unknown, extra: Record = {}) { + try { + await run(lead_refs, extra); + return null; + } catch (e) { + return e as { code?: string; message?: string; hint?: string }; + } +} + +describe("leadbay_qualify_leads — malformed lead_refs", () => { + it("refuses a null ref with a named error, not a TypeError", async () => { + const e = await refusal([null], { qualify: true, confirm: true }); + expect(e).toBeTruthy(); + expect(e).not.toBeInstanceOf(TypeError); + expect(e!.code).toBe("INVALID_LEAD_REF"); + }); + + it("refuses before any network call — nothing is submitted", async () => { + // The whole point: the throw used to happen while deriving the key, which + // is past the spend gate's quote path. Nothing may reach the wire. + await refusal([null], { qualify: true, confirm: true }); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("names the offending indexes so the caller can fix the batch", async () => { + const e = await refusal([{ website: "acme.com" }, null, 42], { + qualify: true, + confirm: true, + }); + expect(e!.code).toBe("INVALID_LEAD_REF"); + // Index-and-reason per bad entry, so the caller can fix the batch rather + // than bisect it. Ref 0 is well-formed and must not be listed. + expect(e!.message).toMatch(/1 \(not an object\)/); + expect(e!.message).toMatch(/2 \(not an object\)/); + expect(e!.message).not.toMatch(/\b0 \(/); + }); + + it("refuses on the FREE path too — a crash is not free-path-specific", async () => { + const e = await refusal([null], { qualify: false }); + expect(e!.code).toBe("INVALID_LEAD_REF"); + }); + + it("rejects arrays and numbers where a ref object belongs", async () => { + expect((await refusal([[]], { qualify: false }))!.code).toBe("INVALID_LEAD_REF"); + expect((await refusal([7], { qualify: false }))!.code).toBe("INVALID_LEAD_REF"); + }); + + it("still accepts the bare-string shorthand", async () => { + // The guard runs AFTER normalizeLeadRefs, so a string is already an object + // by then and must not be called malformed. + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/qualify", + status: 200, + body: { job_id: "job-1", state: "queued", items: [] }, + }, + { + method: "GET", + path: /^\/1\.6\/mcp\/jobs\//, + status: 200, + body: { + job: { id: "job-1", state: "succeeded" }, + funnel: { delivered: 0, examined: 0 }, + items: [], + }, + }, + ]); + await qualifyLeads.execute(newClient(), { + lead_refs: ["acme.com"], + qualify: false, + wait_seconds: 0, + } as any); + const post = getHttpRequests().find((r) => r.method === "POST"); + expect(JSON.parse(post!.body ?? "{}").lead_refs).toEqual([ + { website: "acme.com" }, + ]); + }); +}); diff --git a/packages/core/test/unit/composite/qualify-ref-field-types.test.ts b/packages/core/test/unit/composite/qualify-ref-field-types.test.ts new file mode 100644 index 00000000..e1d88e8a --- /dev/null +++ b/packages/core/test/unit/composite/qualify-ref-field-types.test.ts @@ -0,0 +1,116 @@ +/** + * Lead-ref FIELD types are validated, not just the ref container. + * + * The first guard checked that each entry was an object. `{website: 123}` + * cleared it and then died one level in: `normalizeDomain` returns null for a + * non-string, so the `??` fallback ran `123.trim()` — a raw TypeError while + * deriving the automatic idempotency key, again BEFORE the spend gate could + * return its quote. `name` and `location` had the same hole via `?.trim()`, + * which guards null/undefined but not a number. + * + * Two layers are pinned here: the guard rejects non-string fields with + * INVALID_LEAD_REF, and key derivation itself no longer throws on one — so a + * future caller that reaches it without the guard degrades instead of crashing. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { qualifyLeads } from "../../../src/composite/qualify-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +beforeEach(() => resetHttpMock()); + +/** The PAID, unkeyed path — the one that derives a fallback request_id. */ +async function refusal(lead_refs: unknown) { + mockHttp([]); + try { + await qualifyLeads.execute(newClient(), { + lead_refs, + qualify: true, + confirm: true, + wait_seconds: 0, + } as any); + return null; + } catch (e) { + return e as { code?: string; message?: string }; + } +} + +const OK_JOB = [ + { + method: "POST" as const, + path: "/1.6/mcp/qualify", + status: 200, + body: { job_id: "job-1", state: "queued", items: [] }, + }, + { + method: "GET" as const, + path: /^\/1\.6\/mcp\/jobs\//, + status: 200, + body: { + job: { id: "job-1", state: "succeeded" }, + funnel: { delivered: 0, examined: 0 }, + items: [], + }, + }, +]; + +describe("leadbay_qualify_leads — lead-ref field types", () => { + it("refuses a numeric website instead of throwing a TypeError", async () => { + const e = await refusal([{ website: 123 }]); + expect(e).toBeTruthy(); + expect(e).not.toBeInstanceOf(TypeError); + expect(e!.code).toBe("INVALID_LEAD_REF"); + expect(e!.message).toMatch(/0\.website \(number\)/); + }); + + it("refuses non-string name and location too", async () => { + expect((await refusal([{ name: 42 }]))!.message).toMatch(/0\.name \(number\)/); + expect((await refusal([{ location: {} }]))!.message).toMatch( + /0\.location \(object\)/ + ); + }); + + it("refuses an explicit null field", async () => { + // `?? null` and `?.` both tolerate null, so this never crashed — but it is + // still not a usable identifier and must not reach the backend silently. + expect((await refusal([{ website: null }]))!.message).toMatch( + /0\.website \(null\)/ + ); + }); + + it("refuses nothing before the wire — no submit happens", async () => { + await refusal([{ website: 123 }]); + expect(getHttpRequests()).toHaveLength(0); + }); + + it("reports every bad field across every ref", async () => { + const e = await refusal([{ website: "acme.com" }, { name: 1, location: 2 }]); + expect(e!.message).toMatch(/1\.name/); + expect(e!.message).toMatch(/1\.location/); + expect(e!.message).not.toMatch(/0\./); + }); + + it("still accepts absent fields and well-formed refs", async () => { + mockHttp(OK_JOB); + await qualifyLeads.execute(newClient(), { + lead_refs: [{ website: "acme.com" }, { name: "Acme", location: "Paris" }], + qualify: false, + wait_seconds: 0, + } as any); + expect( + getHttpRequests().filter((r) => r.method === "POST") + ).toHaveLength(1); + }); +}); diff --git a/packages/core/test/unit/composite/qualify-title-gate-key.test.ts b/packages/core/test/unit/composite/qualify-title-gate-key.test.ts new file mode 100644 index 00000000..d77b80ec --- /dev/null +++ b/packages/core/test/unit/composite/qualify-title-gate-key.test.ts @@ -0,0 +1,100 @@ +/** + * `title_gate` canonicalization on the qualify idempotency key. + * + * With `contact_titles` present the backend applies `prefer` when the field is + * omitted. The key hashed the omission as `null`, so an approval that left the + * field out and an unkeyed retry that materialized the same documented default + * described identical work under two different `qualify-auto-*` keys — and the + * retry escaped dedupe into a second PAID qualification and channel purchase. + * + * The search path already canonicalized this; qualify did not. Same class as + * the `exploration_cap` fix, same consequence: a double charge on a retry. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { qualifyLeads } from "../../../src/composite/qualify-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +beforeEach(() => resetHttpMock()); + +/** PAID path — the only one that derives a key. */ +async function paidKey(extra: Record): Promise { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/qualify", + status: 200, + body: { job_id: "job-1", state: "queued", items: [] }, + }, + { + method: "GET", + path: /^\/1\.6\/mcp\/jobs\//, + status: 200, + body: { + job: { id: "job-1", state: "succeeded" }, + funnel: { delivered: 0, examined: 0 }, + items: [], + }, + }, + ]); + await qualifyLeads.execute(newClient(), { + lead_refs: [{ website: "acme.com" }], + qualify: true, + confirm: true, + wait_seconds: 0, + ...extra, + } as any); + const post = getHttpRequests().find( + (r) => r.method === "POST" && r.path.endsWith("/mcp/qualify") + ); + return JSON.parse(post!.body ?? "{}").request_id; +} + +describe("leadbay_qualify_leads — title_gate key canonicalization", () => { + it("an omitted title_gate keys the same as the applied default", async () => { + const omitted = await paidKey({ contact_titles: ["Head of Ops"] }); + const explicit = await paidKey({ + contact_titles: ["Head of Ops"], + title_gate: "prefer", + }); + expect(omitted).toBeTruthy(); + expect(omitted).toEqual(explicit); + }); + + it("an explicit non-default title_gate still keys distinctly", async () => { + const omitted = await paidKey({ contact_titles: ["Head of Ops"] }); + const strict = await paidKey({ + contact_titles: ["Head of Ops"], + title_gate: "strict", + }); + expect(omitted).not.toEqual(strict); + }); + + it("with no contact_titles the default is absent, not 'prefer'", async () => { + // Nothing to gate on, so the backend applies no gate — canonicalizing to + // "prefer" here would collapse two genuinely different asks. + const bare = await paidKey({}); + const prefer = await paidKey({ title_gate: "prefer" }); + expect(bare).not.toEqual(prefer); + }); + + it("matches the search path's rule", async () => { + // Both tools must materialize the same default, or the same approval + // hashes differently depending on which tool the agent reached for. + const a = await paidKey({ contact_titles: ["VP People"] }); + const b = await paidKey({ contact_titles: ["VP People"], title_gate: "prefer" }); + expect(a).toEqual(b); + }); +}); diff --git a/packages/core/test/unit/composite/truncated-terminal-continuation.test.ts b/packages/core/test/unit/composite/truncated-terminal-continuation.test.ts new file mode 100644 index 00000000..de24ff23 --- /dev/null +++ b/packages/core/test/unit/composite/truncated-terminal-continuation.test.ts @@ -0,0 +1,115 @@ +/** + * A finished job can still owe rows. + * + * When the page drain runs out of budget AFTER the backend job has gone + * terminal, `done` is true — so `next_poll` was null — while `items_truncated` + * is also true. The rendering rule tells the agent to fetch the rest with + * `leadbay_lead_job_status(job_id, since: next_since)`, but neither the cursor + * nor a continuation action was present anywhere in the response. The rows were + * paid for and simply unreachable. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, httpsMockFactory } from "../../harness.js"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { findNewLeads } from "../../../src/composite/find-new-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +beforeEach(() => resetHttpMock()); + +// A page that is FULL and still carries a cursor — the drain's "more to come" +// signal — on a job the backend already marked completed. +const fullPage = (cursor: string) => ({ + job: { state: "completed" }, + items: Array.from({ length: 100 }, (_, i) => ({ + id: `${cursor}-${i}`, + status: "delivered", + company: { name: "Acme" }, + })), + funnel: { delivered: 250 }, + cost: { spent: 0 }, + next_since: cursor, +}); + +describe("truncated + terminal — the rest stays reachable", () => { + it("returns the cursor and a continuation even though done is true", async () => { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/search", + status: 200, + body: { job_id: "job-t", state: "queued", items_requested: 250 }, + }, + // Every page is full + cursored, so the drain stops on its own budget. + ...Array.from({ length: 30 }, (_, i) => ({ + method: "GET" as const, + path: new RegExp("^/1\\.6/mcp/jobs/job-t"), + status: 200, + body: fullPage(`cur-${i}`), + })), + ]); + + const res: any = await findNewLeads.execute( + newClient(), + { + example_lead: { description: "independent gym" }, + count: 50, + request_id: "trunc-test", + wait_seconds: 0, + } as any, + {} as any + ); + + expect(res.done).toBe(true); + expect(res.items_truncated).toBe(true); + // The two things the rendering rule needs, both previously absent here. + expect(res.next_since).toBeTruthy(); + expect(res.next_poll).not.toBeNull(); + expect(res.next_poll.tool).toBe("leadbay_lead_job_status"); + expect(res.next_poll.job_id).toBe("job-t"); + expect(res.next_poll.since).toBe(res.next_since); + // It is a page fetch, not a wait — nothing is still running. + expect(res.next_poll.suggested_wait_seconds).toBe(0); + }); + + it("still returns no continuation for a clean, complete job", async () => { + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/search", + status: 200, + body: { job_id: "job-c", state: "queued", items_requested: 5 }, + }, + { + method: "GET", + path: new RegExp("^/1\\.6/mcp/jobs/job-c"), + status: 200, + body: { + job: { state: "completed" }, + items: [], + funnel: { delivered: 0 }, + cost: { spent: 0 }, + next_since: null, + }, + }, + ]); + + const res: any = await findNewLeads.execute( + newClient(), + { + example_lead: { description: "independent gym" }, + count: 5, + request_id: "clean-test", + wait_seconds: 0, + } as any, + {} as any + ); + + expect(res.done).toBe(true); + expect(res.items_truncated).toBe(false); + expect(res.next_poll).toBeNull(); + }); +}); diff --git a/packages/core/test/unit/composite/zero-wait-cancellation.test.ts b/packages/core/test/unit/composite/zero-wait-cancellation.test.ts new file mode 100644 index 00000000..9b5a87f6 --- /dev/null +++ b/packages/core/test/unit/composite/zero-wait-cancellation.test.ts @@ -0,0 +1,98 @@ +/** + * Cancellation reaches the ZERO-WAIT snapshot paths too. + * + * Threading the signal through `waitForJob` covered `wait_seconds > 0` and + * left the three direct `collectJobSnapshot` calls — the default + * `wait_seconds: 0` branch of lead_job_status, find_new_leads and + * qualify_leads — passing no signal at all. A cancelled status poll therefore + * still opened a GET and sat on it, which is the same defect the previous fix + * was supposed to close. + * + * The guard now lives INSIDE collectJobSnapshot, so every call site inherits + * it rather than each needing to remember. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { collectJobSnapshot } from "../../../src/composite/_mcp-job-helpers.js"; +import { leadJobStatus } from "../../../src/composite/lead-job-status.js"; +import { findNewLeads } from "../../../src/composite/find-new-leads.js"; + +const BASE = "https://api-us.leadbay.app"; +const newClient = () => new LeadbayClient(BASE, "u.tok", "us"); + +beforeEach(() => resetHttpMock()); + +const aborted = () => { + const ac = new AbortController(); + ac.abort(); + return ac.signal; +}; + +describe("collectJobSnapshot — the guard every caller inherits", () => { + it("throws REQUEST_CANCELLED and opens no request", async () => { + mockHttp([]); + await expect( + collectJobSnapshot(newClient(), "job-1", undefined, undefined, aborted()) + ).rejects.toMatchObject({ code: "REQUEST_CANCELLED" }); + expect(getHttpRequests()).toHaveLength(0); + }); +}); + +describe("leadbay_lead_job_status — wait_seconds: 0", () => { + it("a cancelled poll never reaches the network", async () => { + mockHttp([]); + await expect( + leadJobStatus.execute( + newClient(), + { job_id: "job-1", wait_seconds: 0 } as any, + { signal: aborted() } as any + ) + ).rejects.toMatchObject({ code: "REQUEST_CANCELLED" }); + expect(getHttpRequests()).toHaveLength(0); + }); +}); + +describe("leadbay_find_new_leads — wait_seconds: 0", () => { + it("a submit cancelled BEFORE dispatch never reaches the network", async () => { + // Superseded contract: this used to assert the POST still landed, on the + // reasoning that a paid submit may already have committed server-side. That + // is true only ONCE IT IS ON THE WIRE. A submit still queued behind the + // client's concurrency slots has provably spent nothing, so cancelling it + // there is free — and letting it through charged the user for a job they + // had already cancelled. The in-flight half of the rule is unchanged and is + // covered in paid-submit-presend-cancel.test.ts. + mockHttp([ + { + method: "POST", + path: "/1.6/mcp/search", + status: 200, + body: { job_id: "job-1", state: "queued", items: [] }, + }, + ]); + await expect( + findNewLeads.execute( + newClient(), + { + example_lead: { description: "independent gym" }, + count: 5, + request_id: "cancel-test", + wait_seconds: 0, + } as any, + { signal: aborted() } as any + ) + ).rejects.toMatchObject({ code: "REQUEST_CANCELLED" }); + const reqs = getHttpRequests(); + expect(reqs.filter((r) => r.method === "POST")).toHaveLength(0); + expect(reqs.filter((r) => r.method === "GET")).toHaveLength(0); + }); +}); diff --git a/packages/core/test/unit/mcp-first-delivery-gate.test.ts b/packages/core/test/unit/mcp-first-delivery-gate.test.ts new file mode 100644 index 00000000..b320930e --- /dev/null +++ b/packages/core/test/unit/mcp-first-delivery-gate.test.ts @@ -0,0 +1,83 @@ +/** + * Release gate for the MCP-first delivery tools. + * + * `/1.6/mcp/search`, `/1.6/mcp/qualify` and `/1.6/mcp/jobs/{id}` are live on + * staging only — production returns 404. Until the backend ships, the three + * tools must NOT appear on the default surface, or every user gets tools that + * fail on their first call. + * + * The module reads the env var at import time, so each case re-imports with a + * reset module registry rather than mutating a cached catalogue. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const DELIVERY_TOOLS = [ + "leadbay_find_new_leads", + "leadbay_qualify_leads", + "leadbay_lead_job_status", +]; + +const FLAG = "LEADBAY_MCP_LEAD_DELIVERY"; + +async function loadCatalogues(flag: string | undefined) { + vi.resetModules(); + const previous = process.env[FLAG]; + if (flag === undefined) delete process.env[FLAG]; + else process.env[FLAG] = flag; + try { + const mod = await import("../../src/index.js"); + return [ + ...mod.compositeReadTools, + ...mod.compositeWriteTools, + ].map((t) => t.name); + } finally { + if (previous === undefined) delete process.env[FLAG]; + else process.env[FLAG] = previous; + } +} + +beforeEach(() => vi.resetModules()); + +// Each case resets the module registry and re-imports the full core index, a +// large graph that can take >1s to re-evaluate — well past vitest's 5s default +// once a case does it twice. +describe("MCP-first delivery release gate", { timeout: 30_000 }, () => { + it("hides all three tools by default", async () => { + const names = await loadCatalogues(undefined); + for (const tool of DELIVERY_TOOLS) { + expect(names).not.toContain(tool); + } + }); + + it("exposes all three when the flag is set to 1", async () => { + const names = await loadCatalogues("1"); + for (const tool of DELIVERY_TOOLS) { + expect(names).toContain(tool); + } + }); + + it("treats any other flag value as off", async () => { + const names = await loadCatalogues("true"); + for (const tool of DELIVERY_TOOLS) { + expect(names).not.toContain(tool); + } + }); + + it("keeps them registered for contract audits regardless of the gate", async () => { + vi.resetModules(); + delete process.env[FLAG]; + const mod = await import("../../src/index.js"); + const audited = mod.mcpFirstDeliveryAllTools.map((t) => t.name); + for (const tool of DELIVERY_TOOLS) { + expect(audited).toContain(tool); + } + }); + + it("does not disturb the rest of the catalogue", async () => { + const gatedOff = await loadCatalogues(undefined); + const gatedOn = await loadCatalogues("1"); + const difference = gatedOn.filter((n) => !gatedOff.includes(n)).sort(); + expect(difference).toEqual([...DELIVERY_TOOLS].sort()); + }); +}); diff --git a/packages/mcp/src/prompts.generated.ts b/packages/mcp/src/prompts.generated.ts index 1fcae8a8..02653872 100644 --- a/packages/mcp/src/prompts.generated.ts +++ b/packages/mcp/src/prompts.generated.ts @@ -1456,6 +1456,162 @@ Tell me the outreach was logged, name the verification.source used, and surface `; // endregion: leadbay_log_outreach +// region: leadbay_new_leads +export const leadbay_new_leads: string = ` +## MEMORY + +Before responding, glance at any \`_meta.agent_memory.summary\` returned by tool calls earlier in this session and reflect its top signals in your reasoning ("Filtering by your stated preference for healthcare"). After any material new signal from the user this conversation (sector, region, deal size, communication style, qualification rule, explicit retraction, or recurrence / scheduling preference such as "I do this every day" or "remind me every morning"), call \`leadbay_agent_memory_capture\` to persist it: \`source:"user_stated"\` if literal, \`source:"inferred"\` with confidence <=6 if inferred. + + +IRON LAW — NO FABRICATION. Every lead id, contact email, custom field id, mapping decision, and tool argument must trace to a value you read from the file the user attached or to an output from a leadbay_* tool call in this session. Do not invent values. Do not "fill in" a missing leadId with a name match. Do not synthesize a CRM id from a guess. If a value is missing, leave the field blank and say so. + + +GATE — DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim — score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it. + +If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it. + + +Find net-new leads for me. My need, in my words: + +> {{arg:need}} + +If that need was not supplied to you directly, take it from the message that +started this — the request in my own words is the need, and I should never be +asked to repeat something I already said. Only when BOTH are missing or too +vague to name (a) who I sell to and (b) roughly how many leads I want, ask me +ONCE — one short question — then proceed. Default count when unstated: 10. + +# PHASE 1 — UNDERSTAND THE BUYER (no tool calls yet) + +From my words, work out: +- What I SELL and therefore WHO WRITES ME CHECKS — the buyer category, never + the buyer's customers, never my competitors. If my product helps companies + of type X serve audience Y, my buyer is X. +- Hard constraints: geography, size band, sector, exclusions ("no + franchises", "pas de grands groupes" — negatives BIND). +- Contact needs: do I want a person? Which titles? Email, phone, both? +- Buyer archetypes: if my need genuinely spans two different kinds of buyer, + plan one search per archetype — never one blended seed. + +# PHASE 2 — CRAFT THE SEED + +Compose the \`example_lead\` for each archetype following the craft rules in +the leadbay_find_new_leads description (registry-style description of a +FICTIONAL typical buyer; no invented brand name; no event language; hard +constraints go in \`filters\` with the FLAT keys \`employees_min\`/\`employees_max\` +and city/state/region \`locations\` — never a country name). Show me the seed +description(s) in one line each — I should recognize my ideal customer in +them. + +\`filters\` only encodes sectors, locations and employee bounds. Any constraint +that does not fit those keys — above all EXCLUSIONS like "no franchises" or +"pas de grands groupes" — has nowhere to live in the filter schema, so it must +not be dropped on the floor: express it positively in the seed \`description\` +(an independent single-site operator rather than "no franchises"), and carry +the exclusion forward yourself to Phase 5, where you drop violating rows and +say you dropped them. Tell me plainly if a constraint can only be enforced +that way — after the fact, not by the search. + +Composing this fictional seed from my words is expected and permitted: it is +the tool's designed input, not fabricated data. What must never be invented is +a RESULT — company names, contacts, scores, or anything presented as coming +back from Leadbay. + +# PHASE 3 — FREE PREVIEW + +Call \`leadbay_find_new_leads\` with the seed, \`filters\`, \`count\`, +\`qualify: false\`, no channels — this is FREE — and a \`request_id\` derived +from the ask + the ARCHETYPE + today's date. \`count\` is the TOTAL I asked +for, not a per-search number: with two archetypes and a request for 10, +split it (5 + 5, or whatever weighting fits my ask) rather than sending 10 +to each — otherwise I get 20 leads and, on the paid pass, pay for 20. + +When you RETRY a search — it timed out, or the job is still live — reuse the +\`request_id\` you already sent, verbatim. Do not recompute it: rederiving from +"today's date" after midnight yields a new key, the backend cannot dedupe, and +a second paid, novelty-claiming search launches. Roll the date only when I am +genuinely asking for a new batch. The archetype component is not +optional: \`request_id\` is the idempotency key, so two archetype searches +sharing one id dedupe to the same job and the second archetype is never +searched. Render the delivery table and judge fit honestly: are these the +kind of companies I asked for? + +- **\`still_running: true\`** → the job is ALIVE. Do not judge the seed and do + not relaunch — poll \`leadbay_lead_job_status\` (\`wait_seconds: 60\`) until + it goes terminal, reporting progress. Relaunching now burns an active-job + slot and rate-limit budget on a search that may be about to deliver. +- **On-profile** (terminal) → offer Phase 4. +- **Off-profile or empty** (terminal) → read \`funnel\` + + \`explain.scope_notes\`, tell me what went wrong in one line (wrong + archetype? too narrow a filter? thin universe?), reshape the seed or + filters, and retry under a NEW request_id. Reshaping is free; do not pay + to explore a bad seed. + +# PHASE 4 — PAID DEPTH (only with my explicit go-ahead) + +When I want qualification evidence and/or reachable contacts: +1. Quote first: \`dry_run: true\` on the tool you will actually run, with the + exact flags I asked for, and tell me the worst-case cost in plain money. + The two tools take DIFFERENT flags — passing the wrong one is rejected + outright (\`additionalProperties: false\`): + - \`leadbay_qualify_leads\`: \`qualify: true\`, \`contact_titles\`, + \`title_gate\`, \`channels\`, \`max_cost\`. **No \`min_ai_score\`.** + - \`leadbay_find_new_leads\`: the same, PLUS \`min_ai_score\` and \`count\`. +2. On my go-ahead, prefer feeding the free preview's deliveries to + \`leadbay_qualify_leads\` (\`prior_deliveries: {job_id}\`) — one paid pass PER + preview job when Phase 3 ran several archetypes, or merge their delivered + refs into a single \`lead_refs\` call. Never qualify just the first job and + call it done: the other archetypes are part of what I asked for. It only + spends on + companies already known to match. Paid calls need \`confirm: true\`; without + it the tool withholds the submit and hands back a quote instead of + spending. That applies to \`leadbay_find_new_leads\` too whenever you set + \`qualify: true\` or ask for channels. + + If the preview delivered FEWER than I asked for, do both halves and do not + conflate them: qualify what the preview already found, and run the fresh + search only for the SHORTFALL — \`count\` = what is still missing, never the + original number, under a NEW \`request_id\`. Reusing the preview's id dedupes + the paid submit back into the free job; keeping the original count buys a + whole second batch, because \`novelty: org\` already excludes everything the + preview delivered. + + The same arithmetic applies AFTER the paid pass. A full-count preview can + still end short once qualification disqualifies rows or a strict title / + channel match misses: what I asked for is n QUALIFIED, CONTACTABLE leads, + not n examined. Count the delivered-and-callable rows; if they fall short, + tell me the gap in one line and offer to top it up — another shortfall-sized + search under a NEW \`request_id\`, quoted first like any paid run. Never + silently hand back fewer than I asked for and paid toward. + + Pass the leads already EXAMINED-AND-REJECTED into that top-up's + \`exclude_lead_ids\` — disqualified and skipped, from both the preview and + the paid pass. \`novelty: org\` already excludes prior DELIVERIES, so + delivered ids are redundant there; the rejected ones are exactly what it + misses, and without them the top-up re-picks the same misses and charges + again to close no gap. **\`exclude_lead_ids\` caps at 500** — a wide + \`exploration_cap\` can examine more than that, so send the most recent 500 + rejects rather than an over-long list the tool refuses outright. +3. While the job runs, poll with \`leadbay_lead_job_status\` + (\`wait_seconds: 60\`); report progress, not silence. + +# PHASE 5 — DELIVER + +Before rendering, sanity-check every row: geography inside my fence (drop +and call out same-named-city leaks), descriptions actually matching my ask +(especially when \`explain.seed_strategy\` is \`text_match_exemplars\` — fit +scores run hot there), visible violations of my exclusions dropped. If the +best fit is under 30, say "weak matches only" and propose reshaping before +showing more than 3. + +Render per the lead-delivery table, then ALWAYS the funnel line: matched / +examined / qualified / disqualified / delivered / stop reason / spend. Zero +delivered gets a diagnosis and a concrete next move, never a shrug. Close +with NEXT STEPS from the tool description — and STOP; take no further action +without my say-so. +`; +// endregion: leadbay_new_leads + // region: leadbay_plan_tour_in_city export const leadbay_plan_tour_in_city: string = ` Plan a field sales tour for me in **{{arg:city}}**{{arg:date_paren}}. @@ -2611,6 +2767,7 @@ export const PROMPT_META = { leadbay_getting_started: {"name":"leadbay_getting_started","short_description":"Guided first-run walkthrough — four clicks that actually use Leadbay: check\nthe account, pull today's leads, draft a first email to the top one, then\nreveal who to send it to. Use when the user is new or asks to be SHOWN how\nLeadbay works (\"walk me through Leadbay\", \"I'm new\", \"how do I use this\",\n\"give me a tour\"). Don't use it for orientation prose with no clicking —\nthat's leadbay_prospecting_overview.\n","arguments":[],"expected_calls":["leadbay_account_status","leadbay_pull_leads","leadbay_prepare_outreach","leadbay_enrich_titles","leadbay_bulk_enrich_status"],"failure_modes":["Presents a gate as prose (\"let me know if you want me to pull your leads\") instead of CALLING the host choice widget — the click IS the lesson, and prose turns the walkthrough into a lecture","Runs a step's tool WITHOUT firing that step's widget first and waiting for the click — the walkthrough becomes an automated demo the user only watches, which is the exact opposite of learning by doing","Fires the widget without the EXPLAIN beat, so the user gets an unexplained button and learns nothing about what a lens or an enrichment actually is","Answers gate 1 with a bare \"you're connected as X at Y\" when the quota IS readable — the user clicked a button labelled `check my account status`, so the quota windows (Daily/Weekly/Monthly gauges, % used, $ spent, resets) ARE the answer, not an optional extra","Renders quota as raw \"credits\" instead of the web app's percentage + dollar-spend gauges, or dumps raw `resource_type` strings the user has never seen","Opens with a wall of text — previewing all four steps, explaining lenses up front, or writing several paragraphs before the first widget. The opening is TWO lines then the button; a first-run user wants to see it work, not read a syllabus","Ends the first message without firing gate 1's widget, leaving the user to reply \"ok\" before anything happens","Rewrites the gate's own `next_steps` payload (its `question`, `label` or `description`) instead of mapping it into the widget verbatim, or merges two gates into a single multi-option widget","Fires a THIRD option, or turns the exit into an alternative route (\"show me my lenses instead\") — each gate carries exactly one forward action plus the `I'm done for now` exit, never a menu of paths","Fires a single-option widget — the host requires 2–4 options, so a lone option is rejected or silently degrades to prose (\"say the word and I'll check it\"), which is the exact defect this rule exists to prevent","Launches the PAID reveal at gate 4 BEFORE the user has picked leads and confirmed — beat 1 must be the free `mode:\"discover\"` preview (no `titles`, no `confirm`, no `email`, no `phone`); the gate click bought the free look, not the reveal, and silence is never consent","Stops at the free preview after the user DID pick leads and confirm — they asked for real contact details, so the second call must actually run with `confirm:true` and the chosen titles","Reports the enrichment without polling `leadbay_bulk_enrich_status` to completion, so it claims contacts it never actually saw resolve","Reveals contacts and never says what it cost — the user just spent credits and deserves the one-line \"N contacts = N credits\", which is also what makes gate 1's quota numbers concrete","Reports \"no leads\" on an empty batch while `computing_wishlist` / `computing_scores` is true — the lens is still building; render the tool's own two-option warm-up widget verbatim and pause","Rewords, reorders or prose-ifies the `next_steps` payload from `leadbay_pull_leads` instead of mapping `options[]` into the widget verbatim","Runs all four steps in one turn without waiting for the user's click between gates — the walkthrough is a sequence of gates, not a script to recite","Skips `leadbay_pull_leads` and jumps straight to enrichment, leaving gate 4 with no `leadIds` to scope","Passes a singular `leadId` to `leadbay_enrich_titles` on the confirmed reveal — that key does not exist on this tool, so it is dropped and the paid call falls back to the whole default wishlist selection, charging for far more than the one lead the user agreed to. it is always the `leadIds` ARRAY, even for a single lead","Drops the pinned `lens.id` between gates, so gate 4 enriches against a different lens than the one the user just saw","Ends the completed walkthrough without the `keep_going` cheat-sheet — the buttons disappear with the tour, so a user who was never told what to TYPE learned to click a tutorial and nothing about using Leadbay tomorrow","Invents phrases for the cheat-sheet, or rewords them into something that sounds nicer but doesn't match the tool's real triggers — teaching a phrase that doesn't route is worse than teaching none","SENDS the gate 3 draft, or offers to send it — the walkthrough drafts and stops there; the email is the user's to judge, and nothing leaves the chat","Passes `enrich:true` to `leadbay_prepare_outreach` at gate 3 — that launches a PAID contact reveal off the back of a DRAFT click, spending credits the user never agreed to","Invents a contact NAME for the gate 3 draft — `recommended_contact` still has null email/name at that point, so the draft is addressed to the job TITLE; a fabricated name is the one thing that makes the whole draft untrustworthy","Treats the null email at gate 3 as a failure — apologising for it, retrying, or calling another tool to fill it in. It is the setup for gate 4 — an email written, nobody to send it to yet","Pastes the drafted email into chat prose alongside `message_compose_v1` instead of letting the composer BE the answer","Enriches leads other than the one it drafted for at gate 3 — gate 4 reveals the person that email is going to, so it is scoped to that ONE lead, one contact, one credit","Renders the cheat-sheet on the exit and stops there, dropping the 1:1 offer — the observed failure is that the agent feels finished once the table is on screen, so the user who just stepped out never hears about the help that would bring them back. ENDING B is not complete without the offer, and the offer goes LAST","Treats the exit click as ENDING C (typed off-script) and closes in silence, or treats a typed request as ENDING B and buries their real answer under a cheat-sheet and a booking link","Turns the exit offer into a pitch — several sentences, a re-opened gate, or an argument for finishing the tour. They said they were done; it is one line and a link","Fires the 1:1 offer mid-tour, or at a user who left by TYPING a different request — a booking link on top of their real question is an interruption, not an offer","Runs the four gates at a user whose actual problem is SETUP — the connector isn't installed, they can't sign in, or their Leadbay tools aren't appearing. The tour assumes a working connection and cannot fix any of it; the setup guide can","Pastes the setup-guide link mid-tour, between gates, instead of once at the closing — a link in the middle of the walkthrough invites the user to leave the thing they're doing"]}, leadbay_import_file: {"name":"leadbay_import_file","short_description":"Import a user-supplied CSV/file into Leadbay through five phases with\nevidence gates — scan, derive, resolve identities, preserve & commit,\nthen optionally qualify and report. The job is to maximize how many\nrows the Leadbay system actually ingests and matches.\n","arguments":[{"name":"file","description":"Path or user-visible name of the CSV/file to import. If omitted, use the file the user attached or referenced.","required":false},{"name":"instruction","description":"Additional user goal, e.g. \"then qualify the leads\", \"preserve owner phone as a custom field\", or \"only import restaurants in Manhattan\".","required":false}],"expected_calls":["leadbay_resolve_import_rows","leadbay_list_mappable_fields","leadbay_create_custom_field","leadbay_import_leads","leadbay_import_and_qualify","leadbay_add_note","leadbay_import_status"],"failure_modes":["Picks LEADBAY_ID from score alone, name-only, fuzzy-name-only, root-domain-only, brand-only, postcode-only, or city-only evidence","Drops meaningful business notes or CRM record links instead of preserving them as custom fields or lead notes","Treats a consumer mailbox domain (gmail.com, hotmail.com, ...) as the company domain","Skips deriving company_domain from a business email when no website column exists (this kills match rate)","Skips the COLUMN PRESERVATION PLAN byproduct before importing","Skips the DECISION LOG byproduct before writing LEADBAY_ID","Returns the imported records WITHOUT writing LEADBAY_ID values back into the user's file (leaves the user no audit trail of what matched)","Fabricates leadIds, contact emails, or mapping IDs not present in the file or a tool response"]}, leadbay_log_outreach: {"name":"leadbay_log_outreach","short_description":"Log outreach (an email I sent, a call I made, a meeting I had) on a\nspecific lead. Captures verification so the SDR pipeline trusts the entry.\n","arguments":[{"name":"lead_id","description":"The lead UUID. Get it from leadbay_pull_leads or leadbay_research_lead_by_id.","required":true},{"name":"summary","description":"1-2 sentences describing what I did (e.g. 'Sent intro email to CTO citing recent Hornsea contract').","required":true}],"expected_calls":["leadbay_report_outreach"],"failure_modes":["Calls leadbay_report_outreach without first collecting a verification source","Fabricates a gmail_message_id or calendar_event_id (the human team treats verification as canonical)","Records outreach to a different lead_id than the one the user supplied","Skips the dry_run step when the user is unsure what would be sent"]}, + leadbay_new_leads: {"name":"leadbay_new_leads","short_description":"Guided net-new lead delivery — turn a natural-language need (\"gyms around\nDallas that would buy our flooring\") into ICP-perfect NEW companies with\nqualification evidence and the right contact, via leadbay_find_new_leads.\nTrigger on \"find me new leads\", \"get me N companies that \",\n\"we're entering \". Do NOT trigger on \"today's leads\"\n(leadbay_daily_check_in) or \"qualify these companies I have\"\n(leadbay_qualify_leads).\n","arguments":[{"name":"need","description":"What the user is looking for, in their own words (e.g. '10 gyms around Dallas that would buy modular flooring, with phone numbers'). Optional — the session starts by asking when absent.","required":false}],"expected_calls":["leadbay_find_new_leads","leadbay_lead_job_status","leadbay_qualify_leads"],"failure_modes":["Passes the user's raw sentence as `query` instead of crafting an example_lead description (vendor-vocabulary trap — measured 0 delivered from a raw query vs on-profile results from a crafted example)","Invents a distinctive brand name in example_lead.name (pulls matching toward name-lookalikes)","Puts event language (\"hiring\", \"expanding\", \"just raised\") into the seed description","Launches qualify:true or channels without a dry_run quote and the user's explicit go-ahead","Retries a failed/timed-out submit with a NEW request_id (double-spend) — the same ask must reuse the same request_id","Reports \"no results\" without narrating the funnel + scope_notes and proposing a concrete fix","Renders delivered leads as freeform prose instead of the canonical lead-delivery table","Blends two distinct buyer archetypes into one seed description instead of running one search per archetype","Passes a country name in filters.locations (silently matches a same-named town — whole-country intent means OMITTING locations) or a nested employees object instead of the flat employees_min/employees_max","Renders rows that visibly violate the user's exclusions, or presents a best-fit-under-30 table as an answer instead of flagging weak matches"]}, leadbay_plan_tour_in_city: {"name":"leadbay_plan_tour_in_city","short_description":"Use whenever the user names a city they'll be in and asks who to see\n— \"I'm in SF next Tuesday, who's worth meeting?\", \"I'm going to Berlin\n— who should I visit?\", \"plan my tour\". Any in-person/visit\nintent tied to a place routes here, NOT to `leadbay_pull_leads`. It\nsurfaces follow-ups + fresh Discover leads in the city via\n`leadbay_tour_plan`, ALWAYS offers to plot them on a map (rendering it\non yes), then offers outreach drafts + campaign persistence.\n","arguments":[{"name":"city","description":"City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups.","required":true},{"name":"date","description":"When the visit is (e.g. 'May 24', 'next Thursday'). Surfaced in the outreach drafts as 'I'll be in on '.","required":false}],"expected_calls":["leadbay_tour_plan","leadbay_research_lead_by_id","leadbay_prepare_outreach","leadbay_create_campaign"],"failure_modes":["Calls leadbay_followups_map (Monitor-only) instead of leadbay_tour_plan — loses the Discover (fresh-lead) half that the user explicitly asked for","Calls leadbay_pull_leads then drops the geo filter — returns the lens-wide wishlist instead of city-relevant fresh leads","Skips the campaign-persist step (\"would you like to save these as a tour?\") — leaves the rep with a one-shot map but no follow-up artifact","Creates a campaign WITHOUT asking the user first — the persist step is high-intent; offer it, don't assume","Fabricates lead_ids when seeding the campaign instead of using the ids returned by tour_plan"]}, leadbay_prospecting_overview: {"name":"leadbay_prospecting_overview","short_description":"Orientation for working with Leadbay from any host — discovery vs.\nfollow-up, the outreach loop, outcome recording, imports, pushback /\nsnooze, and the connected-outreach-tool registry. Trigger when the\nconversation involves Leadbay leads, prospecting, pipeline, follow-up,\noutreach, or lens / ICP — anything from \"show me my leads\" to \"what\nshould I follow up on\" to \"I'll send via lemlist\".\n","arguments":[],"expected_calls":["leadbay_account_status","leadbay_pull_leads","leadbay_pull_followups","leadbay_research_lead_by_id","leadbay_research_lead_by_name_fuzzy","leadbay_prepare_outreach","leadbay_report_outreach","leadbay_set_pushback","leadbay_remove_pushback","leadbay_bulk_qualify_leads","leadbay_enrich_titles","leadbay_import_leads","leadbay_add_note","leadbay_adjust_audience"],"failure_modes":["Drives outreach without asking the user \"how did it go?\" afterwards — leaving prospecting_actions and epilogue_status stale","Says \"epilogue\" in user-facing dialogue instead of \"outcome\"","Says \"Monitor\" in user-facing dialogue instead of \"follow-ups\"","Treats a \"not now / next quarter\" reply as a note instead of routing through the pushback mechanism","Drafts outreach in a generic format when the user has a connected sequencer (lemlist, Outreach.io, etc.) that has its own idiom","Re-pulls leads without passing the captured lensId, allowing a backend lens shift to discard prior work","Skips the STOP byproduct in any multi-step workflow it triggers","Calls leadbay_pull_leads (Discover wishlist) for a follow-up query, or leadbay_pull_followups (Monitor view) for a discovery query — the two entry points read from different backend tables; the right orchestrators are leadbay_daily_check_in (discovery) and leadbay_followup_check_in (follow-up)"]}, leadbay_qualify_top_n: {"name":"leadbay_qualify_top_n","short_description":"Bulk-qualify the top N un-qualified leads in the active lens. Uses\nleadbay_bulk_qualify_leads with a sensible default budget.\n","arguments":[{"name":"count","description":"How many leads to qualify (default 10, max 25). Higher counts may take 5+ minutes.","required":false}],"expected_calls":["leadbay_bulk_qualify_leads","leadbay_qualify_status","leadbay_pull_leads","leadbay_research_lead_by_id"],"failure_modes":["Picks a count larger than the user asked for (or larger than the max 25)","Glosses over still-running leads in the summary instead of naming them","Recommends a lead from the existing qualified pool instead of one from this batch's actual results","Replaces the canonical pull_leads table with prose when rendering the newly-qualified batch (the per-tool RENDERING block is the structural contract; \"standouts\" commentary sits above it)","Expands the qualify-status sentence into a card or table instead of the one-line status-inline render"]}, @@ -2634,6 +2791,7 @@ export const PROMPT_CATALOG_BULLETS = { leadbay_getting_started: `- \`leadbay_getting_started\`: Guided first-run walkthrough — four clicks that actually use Leadbay: check the account, pull today's leads, draft a first email to the top one, then reveal who to send it to. Use when the user is new or asks to be SHOWN how Leadbay works ("walk me through Leadbay", "I'm new", "how do I use this", "give me a tour"). Don't use it for orientation prose with no clicking — that's leadbay_prospecting_overview.`, leadbay_import_file: `- \`leadbay_import_file\` (optional args: file, instruction): Import a user-supplied CSV/file into Leadbay through five phases with evidence gates — scan, derive, resolve identities, preserve & commit, then optionally qualify and report. The job is to maximize how many rows the Leadbay system actually ingests and matches.`, leadbay_log_outreach: `- \`leadbay_log_outreach\` (required args: lead_id, summary): Log outreach (an email I sent, a call I made, a meeting I had) on a specific lead. Captures verification so the SDR pipeline trusts the entry.`, + leadbay_new_leads: `- \`leadbay_new_leads\` (optional args: need): Guided net-new lead delivery — turn a natural-language need ("gyms around Dallas that would buy our flooring") into ICP-perfect NEW companies with qualification evidence and the right contact, via leadbay_find_new_leads. Trigger on "find me new leads", "get me N companies that ", "we're entering ". Do NOT trigger on "today's leads" (leadbay_daily_check_in) or "qualify these companies I have" (leadbay_qualify_leads).`, leadbay_plan_tour_in_city: `- \`leadbay_plan_tour_in_city\` (required args: city; optional args: date): Use whenever the user names a city they'll be in and asks who to see — "I'm in SF next Tuesday, who's worth meeting?", "I'm going to Berlin — who should I visit?", "plan my tour". Any in-person/visit intent tied to a place routes here, NOT to \`leadbay_pull_leads\`. It surfaces follow-ups + fresh Discover leads in the city via \`leadbay_tour_plan\`, ALWAYS offers to plot them on a map (rendering it on yes), then offers outreach drafts + campaign persistence.`, leadbay_prospecting_overview: `- \`leadbay_prospecting_overview\`: Orientation for working with Leadbay from any host — discovery vs. follow-up, the outreach loop, outcome recording, imports, pushback / snooze, and the connected-outreach-tool registry. Trigger when the conversation involves Leadbay leads, prospecting, pipeline, follow-up, outreach, or lens / ICP — anything from "show me my leads" to "what should I follow up on" to "I'll send via lemlist".`, leadbay_qualify_top_n: `- \`leadbay_qualify_top_n\` (optional args: count): Bulk-qualify the top N un-qualified leads in the active lens. Uses leadbay_bulk_qualify_leads with a sensible default budget.`, @@ -2653,6 +2811,7 @@ export const PROMPT_CATALOG_INSTRUCTIONS: string = `This server exposes the foll - \`leadbay_getting_started\`: Guided first-run walkthrough — four clicks that actually use Leadbay: check the account, pull today's leads, draft a first email to the top one, then reveal who to send it to. Use when the user is new or asks to be SHOWN how Leadbay works ("walk me through Leadbay", "I'm new", "how do I use this", "give me a tour"). Don't use it for orientation prose with no clicking — that's leadbay_prospecting_overview. - \`leadbay_import_file\` (optional args: file, instruction): Import a user-supplied CSV/file into Leadbay through five phases with evidence gates — scan, derive, resolve identities, preserve & commit, then optionally qualify and report. The job is to maximize how many rows the Leadbay system actually ingests and matches. - \`leadbay_log_outreach\` (required args: lead_id, summary): Log outreach (an email I sent, a call I made, a meeting I had) on a specific lead. Captures verification so the SDR pipeline trusts the entry. +- \`leadbay_new_leads\` (optional args: need): Guided net-new lead delivery — turn a natural-language need ("gyms around Dallas that would buy our flooring") into ICP-perfect NEW companies with qualification evidence and the right contact, via leadbay_find_new_leads. Trigger on "find me new leads", "get me N companies that ", "we're entering ". Do NOT trigger on "today's leads" (leadbay_daily_check_in) or "qualify these companies I have" (leadbay_qualify_leads). - \`leadbay_plan_tour_in_city\` (required args: city; optional args: date): Use whenever the user names a city they'll be in and asks who to see — "I'm in SF next Tuesday, who's worth meeting?", "I'm going to Berlin — who should I visit?", "plan my tour". Any in-person/visit intent tied to a place routes here, NOT to \`leadbay_pull_leads\`. It surfaces follow-ups + fresh Discover leads in the city via \`leadbay_tour_plan\`, ALWAYS offers to plot them on a map (rendering it on yes), then offers outreach drafts + campaign persistence. - \`leadbay_prospecting_overview\`: Orientation for working with Leadbay from any host — discovery vs. follow-up, the outreach loop, outcome recording, imports, pushback / snooze, and the connected-outreach-tool registry. Trigger when the conversation involves Leadbay leads, prospecting, pipeline, follow-up, outreach, or lens / ICP — anything from "show me my leads" to "what should I follow up on" to "I'll send via lemlist". - \`leadbay_qualify_top_n\` (optional args: count): Bulk-qualify the top N un-qualified leads in the active lens. Uses leadbay_bulk_qualify_leads with a sensible default budget. diff --git a/packages/mcp/src/prompts.ts b/packages/mcp/src/prompts.ts index 0bbe29c6..5d36ec5c 100644 --- a/packages/mcp/src/prompts.ts +++ b/packages/mcp/src/prompts.ts @@ -24,6 +24,7 @@ import { leadbay_getting_started, leadbay_import_file, leadbay_log_outreach, + leadbay_new_leads, leadbay_plan_tour_in_city, leadbay_prospecting_overview, leadbay_qualify_top_n, @@ -80,6 +81,25 @@ const CATALOG: CatalogEntry[] = [ arguments: [], render: () => [userMessage(leadbay_prospecting_overview)], }, + { + name: "leadbay_new_leads", + description: PROMPT_META.leadbay_new_leads.short_description, + arguments: [ + { + name: "need", + description: + "What you're looking for, in your own words (e.g. '10 gyms around Dallas that would buy modular flooring, with phone numbers'). Optional — the session starts by asking when absent.", + required: false, + }, + ], + render: (args) => [ + userMessage( + substitutePlaceholders(leadbay_new_leads, { + need: args.need ?? "(not provided — ask me first)", + }), + ), + ], + }, { name: "leadbay_research_a_domain", description: PROMPT_META.leadbay_research_a_domain.short_description, @@ -366,7 +386,27 @@ const CATALOG: CatalogEntry[] = [ }, ]; -export function listPrompts(): Prompt[] { +/** Prompts whose whole workflow drives tools that are themselves gated off + * until the backend routes ship. Exposing the prompt without the tools would + * let a user start a guided flow whose every call is missing from tools/list. */ +const GATED_PROMPTS: Record boolean> = { + // Needs the rollout flag AND the write surface: every phase calls + // leadbay_find_new_leads / leadbay_qualify_leads, which are write-tier, so a + // read-only server (LEADBAY_MCP_WRITE=0) would offer a workflow whose tools + // are absent from tools/list even with the flag on. + leadbay_new_leads: (opts) => + process.env.LEADBAY_MCP_LEAD_DELIVERY === "1" && opts.includeWrite !== false, +}; + +export interface PromptGateOptions { + /** Mirrors buildServer's includeWrite. Defaults to true (write enabled). */ + includeWrite?: boolean; +} + +/** The full catalogue, gates ignored — contract audits assert every prompt + * named in WORKFLOWS.md resolves, and a rollout flag must not read as + * "this prompt does not exist". */ +export function listAllPrompts(): Prompt[] { return CATALOG.map((c) => ({ name: c.name, description: c.description, @@ -374,14 +414,31 @@ export function listPrompts(): Prompt[] { })); } +export function listPrompts(opts: PromptGateOptions = {}): Prompt[] { + return listAllPrompts().filter((p) => { + const gate = GATED_PROMPTS[p.name]; + return gate ? gate(opts) : true; + }); +} + export function getPrompt( name: string, - args: Record = {} + args: Record = {}, + opts: PromptGateOptions = {} ): GetPromptResult { const entry = CATALOG.find((c) => c.name === name); if (!entry) { throw new Error(`Unknown prompt: ${name}`); } + // Filtering prompts/list is not enough: a cached slash command or a direct + // prompts/get by name would still hand back a workflow whose every tool call + // is missing from tools/list. A gated prompt is unavailable, not just unlisted. + const gate = GATED_PROMPTS[name]; + if (gate && !gate(opts)) { + throw new Error( + `Prompt ${name} is not enabled in this deployment (requires LEADBAY_MCP_LEAD_DELIVERY=1 and the write surface).` + ); + } // Validate required arguments. Per spec, missing required args should // surface as a JSON-RPC error so the client can re-prompt the user. const missing = entry.arguments diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index f5189cde..f28014d3 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -281,7 +281,28 @@ function buildProtocolPrimitivesParagraph(has: (name: string) => boolean): strin "enrich_titles", "bulk_enrich_status", "qualify_status", + // The MCP-first delivery jobs block-poll for 45s by default and up to + // 180s. Without a progressToken ctx.progress is absent, so the call looks + // frozen for minutes — the exact case this paragraph exists to prevent. + // `.filter(has)` keeps the iter-12 invariant: a deployment without the + // delivery flag never sees them named. + "find_new_leads", + "qualify_leads", + "lead_job_status", ].filter((n) => has(`leadbay_${n}`)); + // Cancellation is NOT the same story for both families, so they get separate + // lists. The legacy bulk tools own a bulk-store entry that flips to + // 'cancelled' and makes later status polls return BULK_CANCELLED. The + // delivery jobs own no such record: cancelling stops OUR wait, while the + // backend job keeps running. Naming them in the bulk sentence promised a + // transition that never happens and told the agent to stop polling a job + // that was still live. + const bulkStoreRunners = longRunners.filter( + (n) => !["find_new_leads", "qualify_leads", "lead_job_status"].includes(n) + ); + const deliveryRunners = longRunners.filter((n) => + ["find_new_leads", "qualify_leads", "lead_job_status"].includes(n) + ); const elicitTools = [ "refine_prompt clarifications", "report_outreach.user_confirmed", @@ -309,11 +330,28 @@ function buildProtocolPrimitivesParagraph(has: (name: string) => boolean): strin ); } - if (longRunners.length > 0) { + if (bulkStoreRunners.length > 0 || deliveryRunners.length > 0) { + const clauses: string[] = []; + if (bulkStoreRunners.length > 0) { + clauses.push( + "On " + + bulkStoreRunners.map((n) => `leadbay_${n}`).join(", ") + + " the polling loop exits within \u22642 seconds AND the bulk-store entry transitions to " + + "'cancelled'; subsequent status polls return `BULK_CANCELLED` so the agent stops polling." + ); + } + if (deliveryRunners.length > 0) { + clauses.push( + "On " + + deliveryRunners.map((n) => `leadbay_${n}`).join(", ") + + " the wait exits within \u22642 seconds but the job is BACKEND-owned and keeps running \u2014 " + + "there is no bulk-store entry and no `BULK_CANCELLED`. Any work already paid for still completes; " + + "poll `leadbay_lead_job_status` later to collect it." + ); + } parts.push( - "(2) `notifications/cancelled` — when the user clicks Cancel in the host UI, the polling loop exits " + - "within ≤2 seconds AND the bulk-store entry transitions to 'cancelled'; subsequent status polls " + - "return `BULK_CANCELLED` so the agent stops polling." + "(2) `notifications/cancelled` — when the user clicks Cancel in the host UI. " + + clauses.join(" ") ); } else { parts.push( @@ -645,11 +683,22 @@ export function buildServer( // Prompts: pull-based slash commands the user can invoke directly. // See packages/mcp/src/prompts.ts for the catalog. + // Pass includeWrite through: a prompt whose workflow needs write-tier tools + // must not be offered on a read-only server, or the user gets a slash + // command whose every call is missing from tools/list. + // Normalize to the SAME truthiness the tool list uses above (`if + // (opts.includeWrite)`), so an omitted flag means "no write tools" for the + // prompt gate too rather than being read as write-enabled. + const promptGate = { includeWrite: Boolean(opts.includeWrite) }; server.setRequestHandler(ListPromptsRequestSchema, async () => ({ - prompts: listPrompts(), + prompts: listPrompts(promptGate), })); server.setRequestHandler(GetPromptRequestSchema, async (req) => { - return getPrompt(req.params.name, (req.params.arguments ?? {}) as Record); + return getPrompt( + req.params.name, + (req.params.arguments ?? {}) as Record, + promptGate + ); }); // Resources: URI-addressable read-only payloads (lead://, lens://, org://). diff --git a/packages/mcp/test/audit/cost-cap-rerun-sizing.test.ts b/packages/mcp/test/audit/cost-cap-rerun-sizing.test.ts new file mode 100644 index 00000000..6339bc8f --- /dev/null +++ b/packages/mcp/test/audit/cost-cap-rerun-sizing.test.ts @@ -0,0 +1,46 @@ +/** + * Audit: the max_cost rerun is sized to the shortfall, not the original ask. + * + * A search that stops at its cost cap has delivered PART of the batch. Telling + * the agent only to "raise the cap and continue" makes it re-request the FULL + * original count — a 10-lead ask that delivered 6 asks for 10 more, not 4 — + * and, because `novelty: org` excludes only DELIVERED leads, the rerun also + * pays to re-examine the same examined-and-rejected candidates. Both overspend + * while the action is presented to the user as a continuation. + */ + +import { describe, it, expect } from "vitest"; +import * as Generated from "@leadbay/core/dist/tool-descriptions.generated.js"; + +const G = Generated as unknown as Record; +const D = G.leadbay_find_new_leads; + +// The rerun row, isolated so these assertions cannot be satisfied by wording +// that happens to appear elsewhere in a 16k description. +const ROW = D.split("\n").find((l) => l.includes("stop_reason: max_cost")) ?? ""; + +describe("audit: max_cost rerun sizing", () => { + it("has a rerun row to govern", () => { + expect(ROW, "max_cost NEXT STEPS row missing").not.toBe(""); + }); + + it("sizes the rerun to the remaining gap, not the original count", () => { + expect(ROW).toMatch(/SHORTFALL/); + expect(ROW).toMatch(/items_requested/); + }); + + it("carries the examined-but-rejected ids so the rerun does not re-buy them", () => { + expect(ROW).toMatch(/exclude_lead_ids/); + // Naming WHY: novelty covers delivered leads, so exclusions are the only + // thing that covers the paid misses. + expect(ROW).toMatch(/novelty covers delivered/i); + }); + + it("still requires a new request_id — a same-id resubmit only dedupes onto a live job", () => { + expect(ROW).toMatch(/NEW request_id/); + }); + + it("keeps the tool within its description budget", () => { + expect(D.length).toBeLessThanOrEqual(17000); + }); +}); diff --git a/packages/mcp/test/audit/delivery-cancellation-guidance.test.ts b/packages/mcp/test/audit/delivery-cancellation-guidance.test.ts new file mode 100644 index 00000000..7496dae9 --- /dev/null +++ b/packages/mcp/test/audit/delivery-cancellation-guidance.test.ts @@ -0,0 +1,85 @@ +/** + * Audit: cancellation guidance tells the truth for BOTH job families. + * + * The legacy bulk tools own a bulk-store entry that flips to 'cancelled', so a + * later status poll returns BULK_CANCELLED and the agent knows to stop. The + * MCP-first delivery jobs own no such record: cancelling stops OUR wait while + * the BACKEND job keeps running. + * + * Adding the delivery tools to the shared long-runner list made the + * cancellation sentence promise them a transition that never happens — and + * told the agent to stop polling a job that was still live and still spending. + * The two families now get separate clauses. + */ + +import { describe, it, expect } from "vitest"; +import { buildServerInstructions } from "../../src/server.js"; + +const DELIVERY = [ + "leadbay_find_new_leads", + "leadbay_qualify_leads", + "leadbay_lead_job_status", +]; +const BULK = ["leadbay_bulk_qualify_leads", "leadbay_enrich_titles"]; + +/** The cancellation bullet, isolated from the rest of the instructions. + * Bounded by the paragraph break — bullet (3) is itself conditional on the + * elicitation tools being exposed, so anchoring on it leaked the whole tail + * of the instructions into the clause. */ +function cancellationClause(exposed: string[]): string { + const text = buildServerInstructions(new Set(exposed)); + const start = text.indexOf("(2) `notifications/cancelled`"); + expect(start, "no cancellation bullet in instructions").toBeGreaterThan(-1); + const break_ = text.indexOf("\n\n", start); + const next = text.indexOf("(3) `elicitation/create`", start); + const ends = [break_, next].filter((i) => i > -1); + return text.slice(start, ends.length ? Math.min(...ends) : undefined); +} + +describe("audit: cancellation guidance per job family", () => { + it("never promises BULK_CANCELLED for a delivery job", () => { + // The word may appear — the honest text says there is NO BULK_CANCELLED. + // What must never appear is the PROMISE: a bulk-store transition, or a + // status poll returning that code. + const clause = cancellationClause(DELIVERY); + expect(clause).not.toMatch(/return `BULK_CANCELLED`/); + expect(clause).not.toMatch(/transitions to 'cancelled'/); + expect(clause).toMatch(/no `BULK_CANCELLED`/); + }); + + it("says a cancelled delivery job keeps running backend-side", () => { + const clause = cancellationClause(DELIVERY); + expect(clause).toMatch(/BACKEND-owned|backend-owned/); + expect(clause).toContain("leadbay_lead_job_status"); + }); + + it("still promises BULK_CANCELLED for the bulk tools", () => { + const clause = cancellationClause(BULK); + expect(clause).toContain("BULK_CANCELLED"); + }); + + it("keeps the two families in separate clauses when both are exposed", () => { + const clause = cancellationClause([...BULK, ...DELIVERY]); + // BULK_CANCELLED must be claimed, but never about a delivery tool: the + // delivery names must not appear before the bulk-store promise. + expect(clause).toContain("BULK_CANCELLED"); + const bulkSentenceEnd = clause.indexOf("BULK_CANCELLED"); + const bulkHalf = clause.slice(0, bulkSentenceEnd); + for (const name of DELIVERY) { + expect(bulkHalf, `${name} named inside the bulk-store promise`).not.toContain( + name + ); + } + }); + + it("names all three delivery tools in the progress list still", () => { + // The split must not have dropped them from bullet (1) — being absent + // there is what made the calls look frozen in the first place. + const text = buildServerInstructions(new Set([...BULK, ...DELIVERY])); + const progress = text.slice( + text.indexOf("(1) `notifications/progress`"), + text.indexOf("(2) `notifications/cancelled`") + ); + for (const name of DELIVERY) expect(progress).toContain(name); + }); +}); diff --git a/packages/mcp/test/audit/delivery-long-runners.test.ts b/packages/mcp/test/audit/delivery-long-runners.test.ts new file mode 100644 index 00000000..d05ffc24 --- /dev/null +++ b/packages/mcp/test/audit/delivery-long-runners.test.ts @@ -0,0 +1,65 @@ +/** + * Audit: the delivery jobs are advertised as progress-capable long runners. + * + * `ctx.progress` only exists when the MCP request carried a progressToken, and + * the only thing that tells a client to send one is the "Protocol primitives" + * paragraph in the server instructions. The delivery tools block-poll for 45s + * by default and up to 180s, so if they are missing from that list the call + * looks frozen for minutes and the per-poll progress callback is simply never + * invoked. + * + * Also pinned: the iter-12 invariant that buildServerInstructions never names + * a tool the deployment does not expose. These three are release-gated, so a + * default deployment must not see them advertised. + */ + +import { describe, it, expect } from "vitest"; +import { buildServerInstructions } from "../../src/server.js"; + +const DELIVERY = [ + "leadbay_find_new_leads", + "leadbay_qualify_leads", + "leadbay_lead_job_status", +]; + +/** The paragraph names tools only in the progressToken sentence. */ +function instructionsFor(exposed: string[]): string { + return buildServerInstructions(new Set(exposed)); +} + +describe("audit: delivery tools advertised as long runners", () => { + it("names all three when they are exposed", () => { + const text = instructionsFor([...DELIVERY, "leadbay_pull_leads"]); + const progressLine = text + .split("\n") + .find((l) => l.includes("Pass a progressToken on")); + expect(progressLine, "no progressToken sentence in instructions").toBeTruthy(); + for (const name of DELIVERY) { + expect(progressLine, `${name} missing from the long-runner list`).toContain( + name + ); + } + }); + + it("names none of them on a deployment without the delivery tools", () => { + // iter-12 invariant: never advertise a tool the agent cannot call. + const text = instructionsFor([ + "leadbay_pull_leads", + "leadbay_bulk_qualify_leads", + ]); + for (const name of DELIVERY) { + expect(text, `${name} advertised while not exposed`).not.toContain(name); + } + }); + + it("keeps the legacy long runners listed", () => { + // The delivery tools are an addition, not a replacement. + const text = instructionsFor([ + ...DELIVERY, + "leadbay_bulk_qualify_leads", + "leadbay_enrich_titles", + ]); + expect(text).toContain("leadbay_bulk_qualify_leads"); + expect(text).toContain("leadbay_enrich_titles"); + }); +}); diff --git a/packages/mcp/test/audit/delivery-next-steps-contract.test.ts b/packages/mcp/test/audit/delivery-next-steps-contract.test.ts new file mode 100644 index 00000000..4c606f47 --- /dev/null +++ b/packages/mcp/test/audit/delivery-next-steps-contract.test.ts @@ -0,0 +1,65 @@ +/** + * Audit: the delivery NEXT STEPS tables offer a valid action for every state + * the tools can actually return. + * + * These tables are normative — the agent is told to pick a row and offer it. + * A state with no matching row leaves the agent instructed to offer something + * while holding nothing valid to offer, which is how a terminal `expired` job + * ended up rendered as an empty delivery. + * + * Read from the GENERATED descriptions, not the snippets, so a template that + * stops being included is caught too. + */ + +import { describe, it, expect } from "vitest"; +import { + leadbay_find_new_leads, + leadbay_lead_job_status, +} from "@leadbay/core/dist/tool-descriptions.generated.js"; + +describe("audit: find_new_leads NEXT STEPS", () => { + it("separates the cost-cap stop from the org-quota stop", () => { + // Raising max_cost cannot clear an org quota: re-running burns a submit + // and a rate-limit slot to stop in the same place. + expect(leadbay_find_new_leads).toMatch(/stop_reason: max_cost/); + expect(leadbay_find_new_leads).toMatch(/stop_reason: quota/); + }); + + it("routes a quota stop at quota recovery, not another search", () => { + const row = leadbay_find_new_leads + .split("\n") + .find((l) => l.includes("stop_reason: quota")); + expect(row, "no quota row in the NEXT STEPS table").toBeTruthy(); + expect(row).toMatch(/leadbay_(account_status|create_topup_link|open_billing_portal)/); + expect(row).not.toMatch(/leadbay_find_new_leads/); + }); + + it("does not hard-code a dollar sign on the cap-raise amount", () => { + const row = leadbay_find_new_leads + .split("\n") + .find((l) => l.includes("stop_reason: max_cost")); + expect(row).toBeTruthy(); + expect(row).not.toMatch(/Raise the cap to \$/); + expect(row).toMatch(/currency/i); + }); +}); + +describe("audit: lead_job_status NEXT STEPS", () => { + it("covers every terminal state the tool documents", () => { + // TERMINAL_JOB_STATES: completed, completed_partial, failed, expired. + for (const state of ["completed", "partial", "failed", "expired"]) { + expect( + leadbay_lead_job_status.toLowerCase(), + `${state} has no NEXT STEPS row` + ).toContain(state); + } + }); + + it("gives expired the ledger re-read, not a render", () => { + const row = leadbay_lead_job_status + .split("\n") + .find((l) => l.includes("expired") && l.includes("|")); + expect(row, "no expired row").toBeTruthy(); + expect(row).toMatch(/prior_deliveries/); + }); +}); diff --git a/packages/mcp/test/audit/disqualified-delivered-rendering.test.ts b/packages/mcp/test/audit/disqualified-delivered-rendering.test.ts new file mode 100644 index 00000000..5ed356a4 --- /dev/null +++ b/packages/mcp/test/audit/disqualified-delivered-rendering.test.ts @@ -0,0 +1,50 @@ +/** + * Audit: a delivered-but-disqualified item is not rendered as a prospect. + * + * leadbay_qualify_leads deliberately DELIVERS org-owned companies that failed + * qualification, carrying their negative evidence ("here's why to skip this + * account" is a deliverable). splitItems therefore places them in `leads[]`, + * where the shared delivery recipe renders a fit bar and a "Why it fits" + * column — so a rejected account could be presented as a positive prospect, + * with a high firmographic score, on a PAID result. + * + * The rule belongs only to the tools that can actually emit such an item: + * qualify_leads, and lead_job_status when polling a qualify job. find_new_leads + * filters disqualified candidates out via min_ai_score and never delivers one, + * so it must NOT pay the char budget for a rule it cannot hit. + */ + +import { describe, it, expect } from "vitest"; +import * as Generated from "@leadbay/core/dist/tool-descriptions.generated.js"; + +const G = Generated as unknown as Record; +const EMITTERS = ["leadbay_qualify_leads", "leadbay_lead_job_status"]; + +describe("audit: delivered-but-disqualified rendering", () => { + it("both emitting tools carry the branch", () => { + for (const tool of EMITTERS) { + expect(G[tool], `${tool} missing the rule`).toMatch(/Delivered ≠ endorsed/); + } + }); + + it("the branch names the fields the agent can actually test", () => { + for (const tool of EMITTERS) { + expect(G[tool]).toContain("status_reason"); + expect(G[tool]).toMatch(/ai_score/); + } + }); + + it("it gives them a section of their own, not the fit table", () => { + for (const tool of EMITTERS) { + // Whitespace-tolerant: the snippet is hard-wrapped, so the label can + // legitimately break across a line. + expect(G[tool]).toMatch(/Evaluated\s+—\s+does not fit/); + } + }); + + it("find_new_leads does not carry it — it cannot deliver one", () => { + // Guards the scoping decision: this tool is the budget-critical one, and + // adding a rule it can never hit is how its headroom evaporates. + expect(G.leadbay_find_new_leads).not.toMatch(/Delivered ≠ endorsed/); + }); +}); diff --git a/packages/mcp/test/audit/gated-anti-trigger-header.test.ts b/packages/mcp/test/audit/gated-anti-trigger-header.test.ts new file mode 100644 index 00000000..d27477af --- /dev/null +++ b/packages/mcp/test/audit/gated-anti-trigger-header.test.ts @@ -0,0 +1,65 @@ +/** + * Audit: a cross-route to a release-gated tool carries its condition INSIDE + * the routing header. + * + * Hosts load roughly the first 600 characters of a tool description — that is + * the constraint routing-block.test.ts exists to enforce. A caveat placed in + * the body is therefore invisible to exactly the host that would misroute: + * `leadbay_pull_leads` named `leadbay_find_new_leads` at char ~300 while the + * availability caveat sat at char ~16,100, so a truncating host saw the route + * and never the condition, and sent net-new asks at a tool absent from + * tools/list. + * + * The `gated: true` marker on an anti-trigger now emits "(only if listed)" + * beside the target, inside the block every host reads. + */ + +import { describe, it, expect } from "vitest"; +import * as Generated from "@leadbay/core/dist/tool-descriptions.generated.js"; + +const HEAD = 600; +const GATED_TARGETS = [ + "leadbay_find_new_leads", + "leadbay_qualify_leads", + "leadbay_lead_job_status", +]; + +/** Tools that are always exposed but name a gated tool in their routing. */ +const CROSS_ROUTERS = ["leadbay_pull_leads", "leadbay_extend_lens"]; + +describe("audit: gated cross-routes are conditioned in the header", () => { + it("every gated target named in the header carries the condition", () => { + const offenders: string[] = []; + for (const tool of CROSS_ROUTERS) { + const head = (Generated as Record)[tool].slice(0, HEAD); + for (const target of GATED_TARGETS) { + let i = head.indexOf(target); + while (i !== -1) { + // "(only if listed)" must follow the backticked name closely. + const after = head.slice(i, i + target.length + 24); + if (!after.includes("only if listed")) { + offenders.push(`${tool}: ${target} at ${i} has no condition`); + } + i = head.indexOf(target, i + 1); + } + } + } + expect(offenders, offenders.join("\n")).toEqual([]); + }); + + it("the condition really is inside the loaded window, not the body", () => { + // Guards the fixture: if the routing block stopped emitting the marker, + // the test above would pass vacuously on a head containing no target. + const head = Generated.leadbay_pull_leads.slice(0, HEAD); + expect(head).toContain("leadbay_find_new_leads"); + expect(head).toContain("only if listed"); + }); + + it("ungated cross-routes are left alone", () => { + // The marker is opt-in; a normal route must not sprout a condition. + const head = Generated.leadbay_pull_leads.slice(0, HEAD); + const i = head.indexOf("leadbay_pull_followups"); + expect(i).toBeGreaterThan(-1); + expect(head.slice(i, i + 46)).not.toContain("only if listed"); + }); +}); diff --git a/packages/mcp/test/audit/items-truncated-rendering.test.ts b/packages/mcp/test/audit/items-truncated-rendering.test.ts new file mode 100644 index 00000000..18e7f79e --- /dev/null +++ b/packages/mcp/test/audit/items-truncated-rendering.test.ts @@ -0,0 +1,52 @@ +/** + * Audit: a partial page set is never presented as the whole batch. + * + * collectJobSnapshot stops draining when the caller's wait budget runs out (and + * at the maxPages backstop, and on abort). `items` is then a PREFIX of what the + * job holds, while `funnel.delivered` still reports the backend's full count — + * so an undocumented truncation renders "delivered 40" over 12 rows and reads + * as though 28 leads simply did not exist. + * + * Unlike the disqualified rule, this one is SHARED by all three delivery tools. + * Scoping it to the drain-heavy pair would leave find_new_leads silently + * misreporting on the day its item count crosses a page — the precise failure + * being fixed here — and that divergence risk is worth more than the chars. + */ + +import { describe, it, expect } from "vitest"; +import * as Generated from "@leadbay/core/dist/tool-descriptions.generated.js"; + +const G = Generated as unknown as Record; +const DELIVERY_TOOLS = [ + "leadbay_find_new_leads", + "leadbay_qualify_leads", + "leadbay_lead_job_status", +]; + +describe("audit: items_truncated rendering", () => { + it("every delivery tool carries the rule", () => { + for (const tool of DELIVERY_TOOLS) { + expect(G[tool], `${tool} missing the truncation rule`).toMatch( + /items_truncated/ + ); + } + }); + + it("names the field the agent tests and the recovery it offers", () => { + for (const tool of DELIVERY_TOOLS) { + const d = G[tool]; + // The rows are a prefix... + expect(d, `${tool} does not say the rows are partial`).toMatch(/PREFIX/); + // ...and the cursor is how the rest is fetched. + expect(d, `${tool} does not name the resumption path`).toMatch( + /leadbay_lead_job_status\(job_id, since: next_since\)/ + ); + } + }); + + it("stays within the per-tool description budget", () => { + for (const tool of DELIVERY_TOOLS) { + expect(G[tool].length, `${tool} over budget`).toBeLessThanOrEqual(17000); + } + }); +}); diff --git a/packages/mcp/test/audit/lead-delivery-routing-block.test.ts b/packages/mcp/test/audit/lead-delivery-routing-block.test.ts new file mode 100644 index 00000000..dd4b3356 --- /dev/null +++ b/packages/mcp/test/audit/lead-delivery-routing-block.test.ts @@ -0,0 +1,95 @@ +/** + * Audit: the MCP-first delivery tools carry a usable routing block. + * + * Same contract `routing-block.test.ts` enforces for the tools listed in its + * TOOLS_WITH_ROUTING set — `## WHEN TO USE` inside the 600-char window every + * chat host loads even when truncating, trigger phrases, the shared memory + * pointer, and ≥2 positive AND ≥2 negative examples. Asserted here instead of + * by appending three names to that established set, so the audit file itself + * stays unchanged. + * + * This matters more for these three than for most tools. Each has a + * plausible-looking older neighbour (`leadbay_pull_leads`, + * `leadbay_bulk_qualify_leads`, the other `*_status` pollers), and two of the + * three SPEND. A truncating host that never sees the routing block routes to + * the neighbour, which is a wrong answer on the read path and a wrong charge + * on the write path. + * + * Not re-asserted here: `anti_triggers[].route_to` resolution. That check in + * routing-block.test.ts iterates every registered tool rather than only the + * listed ones, so it already covers these three. + */ + +import { describe, it, expect } from "vitest"; +import { mcpFirstDeliveryAllTools, type Tool } from "@leadbay/core"; + +const ROUTING_HEAD_WINDOW = 600; +const EXAMPLE_WINDOW = 1500; +const MEMORY_POINTER = + "**Memory:** recall + capture via `leadbay_agent_memory_*` tools."; + +const POS_BLOCK_RE = + /Examples that SHOULD invoke this tool:\n([\s\S]+?)(?:\n\n|$)/; +const NEG_BLOCK_RE = + /Examples that should NOT invoke this tool[^:]*:\n([\s\S]+?)(?:\n\n|$)/; + +const DELIVERY_TOOLS: Tool[] = mcpFirstDeliveryAllTools; + +function countBullets(block: RegExpMatchArray | null): number { + if (!block) return 0; + return block[1].split("\n").filter((l) => l.trim().startsWith("- ")).length; +} + +describe("audit: routing block on the MCP-first delivery tools", () => { + it("covers all three delivery tools", () => { + // Guards the fixture: mcpFirstDeliveryAllTools is the ungated registry, so + // a tool dropped from it would silently shrink everything below. + expect(DELIVERY_TOOLS.map((t) => t.name).sort()).toEqual([ + "leadbay_find_new_leads", + "leadbay_lead_job_status", + "leadbay_qualify_leads", + ]); + }); + + it("each has WHEN TO USE in the first 600 chars", () => { + const violations = DELIVERY_TOOLS.filter( + (t) => !t.description.slice(0, ROUTING_HEAD_WINDOW).includes("## WHEN TO USE"), + ).map( + (t) => + `${t.name}: '## WHEN TO USE' missing from first ${ROUTING_HEAD_WINDOW} chars (description length ${t.description.length})`, + ); + expect(violations, violations.join("\n")).toEqual([]); + }); + + it("each lists at least one trigger phrase", () => { + const violations = DELIVERY_TOOLS.filter( + (t) => !t.description.slice(0, ROUTING_HEAD_WINDOW).match(/Trigger phrases: "/), + ).map((t) => `${t.name}: WHEN TO USE block has no trigger phrases`); + expect(violations, violations.join("\n")).toEqual([]); + }); + + it("each carries the shared agent-memory pointer in the first 600 chars", () => { + const violations = DELIVERY_TOOLS.filter( + (t) => !t.description.slice(0, ROUTING_HEAD_WINDOW).includes(MEMORY_POINTER), + ).map((t) => `${t.name}: missing memory pointer in first ${ROUTING_HEAD_WINDOW} chars`); + expect(violations, violations.join("\n")).toEqual([]); + }); + + it("each carries ≥2 positive AND ≥2 negative example messages", () => { + // The negatives are the load-bearing half on these three: every one of + // them sounds like the neighbour it must not route to. + const violations: string[] = []; + for (const t of DELIVERY_TOOLS) { + const head = t.description.slice(0, EXAMPLE_WINDOW); + const posCount = countBullets(head.match(POS_BLOCK_RE)); + const negCount = countBullets(head.match(NEG_BLOCK_RE)); + if (posCount < 2) { + violations.push(`${t.name}: only ${posCount} positive example(s) (need ≥2)`); + } + if (negCount < 2) { + violations.push(`${t.name}: only ${negCount} negative example(s) (need ≥2)`); + } + } + expect(violations, violations.join("\n")).toEqual([]); + }); +}); diff --git a/packages/mcp/test/audit/lead-delivery-routing-fixtures.test.ts b/packages/mcp/test/audit/lead-delivery-routing-fixtures.test.ts new file mode 100644 index 00000000..9e4a6f59 --- /dev/null +++ b/packages/mcp/test/audit/lead-delivery-routing-fixtures.test.ts @@ -0,0 +1,88 @@ +/** + * Audit: the MCP-first delivery routing fixtures name real tools. + * + * The live classifier eval that consumes routing fixtures needs an Anthropic + * key and is not part of `pnpm -r test`, so a fixture referencing a tool that + * was renamed — or never existed — sits green forever. `ROUTING_FIXTURES` has + * had no runner at all since #71 (`bbf5108` removed the classifier eval and + * left the fixture file orphaned), which is exactly how that rot happens. + * + * This audit is the deterministic half: it cannot tell you whether an intent + * routes correctly, but it can prove every tool the fixtures name is real and + * that no fixture contradicts itself. That keeps the fixtures honest between + * eval runs instead of decaying unnoticed. + */ + +import { describe, it, expect } from "vitest"; +import { + compositeReadTools, + compositeWriteTools, + granularReadTools, + granularWriteTools, + mcpFirstDeliveryAllTools, + type Tool, +} from "@leadbay/core"; +import { LEAD_DELIVERY_ROUTING_FIXTURES } from "../eval/tool-descriptions/lead-delivery-routing-fixtures.js"; + +const ALL_TOOLS: Tool[] = [ + ...compositeReadTools, + ...compositeWriteTools, + ...granularReadTools, + ...granularWriteTools, + ...mcpFirstDeliveryAllTools, +]; +const REGISTERED = new Set(ALL_TOOLS.map((t) => t.name)); + +describe("audit: lead-delivery routing fixtures", () => { + it("covers all three delivery tools", () => { + // Guards the fixture set itself: silently losing a tool's cases would + // leave this audit passing over a narrower set than it claims to cover. + const covered = new Set( + LEAD_DELIVERY_ROUTING_FIXTURES.map((f) => f.expected_tool), + ); + expect([...covered].sort()).toEqual([ + "leadbay_find_new_leads", + "leadbay_lead_job_status", + "leadbay_qualify_leads", + ]); + }); + + it("every expected_tool is a registered tool", () => { + const unknown = LEAD_DELIVERY_ROUTING_FIXTURES.filter( + (f) => !REGISTERED.has(f.expected_tool), + ).map((f) => `${f.expected_tool} (intent: "${f.intent}")`); + expect(unknown, unknown.join("\n")).toEqual([]); + }); + + it("every forbidden_tools entry is a registered tool", () => { + // A typo here is worse than useless: the fixture would "pass" its + // false-positive check against a tool that cannot be routed to anyway. + const unknown: string[] = []; + for (const f of LEAD_DELIVERY_ROUTING_FIXTURES) { + for (const name of f.forbidden_tools ?? []) { + if (!REGISTERED.has(name)) { + unknown.push(`${name} (intent: "${f.intent}")`); + } + } + } + expect(unknown, unknown.join("\n")).toEqual([]); + }); + + it("no fixture forbids the tool it expects", () => { + const contradictory = LEAD_DELIVERY_ROUTING_FIXTURES.filter((f) => + (f.forbidden_tools ?? []).includes(f.expected_tool), + ).map((f) => f.intent); + expect(contradictory, contradictory.join("\n")).toEqual([]); + }); + + it("every fixture carries forbidden_tools", () => { + // On the delivery tools the neighbour route is the PAID mistake + // (leadbay_pull_leads instead of a net-new search, bulk_qualify instead of + // the supplied-list path), so the false-positive signal is the point of + // these fixtures rather than an optional extra. + const bare = LEAD_DELIVERY_ROUTING_FIXTURES.filter( + (f) => !f.forbidden_tools?.length, + ).map((f) => f.intent); + expect(bare, bare.join("\n")).toEqual([]); + }); +}); diff --git a/packages/mcp/test/eval/tool-descriptions/lead-delivery-routing-fixtures.ts b/packages/mcp/test/eval/tool-descriptions/lead-delivery-routing-fixtures.ts new file mode 100644 index 00000000..65fd213b --- /dev/null +++ b/packages/mcp/test/eval/tool-descriptions/lead-delivery-routing-fixtures.ts @@ -0,0 +1,85 @@ +/** + * Tool-routing classifier fixtures for the MCP-first delivery tools. + * + * Same (intent, expected-tool, forbidden-tools) shape as `ROUTING_FIXTURES`, + * kept in its own module so the established fixture file stays untouched. + * + * These three tools are the ones most at risk of misrouting, because a + * plausible-looking older tool exists for every one of them: + * + * leadbay_find_new_leads vs leadbay_pull_leads (today's batch from an + * existing lens — NOT a net-new search) + * leadbay_qualify_leads vs leadbay_bulk_qualify_leads / import_leads + * (the in-account batch path, not a supplied list) + * leadbay_lead_job_status vs the three other *_status pollers + * + * The `forbidden_tools` entries are therefore the load-bearing half: routing + * to the neighbour is the failure mode, and on the delivery tools it is a + * PAID one. + * + * Note the deliberate split of duties: the live classifier eval (Sonnet with + * the tool catalog bound) is what measures whether the intents actually + * route, and it needs an API key. `lead-delivery-routing-fixtures.test.ts` + * covers what a deterministic run can prove — that every tool named here is + * real and that no fixture forbids the tool it expects — so a rename or typo + * cannot rot these silently while no eval is running. + */ + +import type { RoutingFixture } from "./routing-fixtures.js"; + +export const LEAD_DELIVERY_ROUTING_FIXTURES: RoutingFixture[] = [ + { + intent: + "Find me 10 gyms around Dallas that would buy our flooring, with someone I can call.", + expected_tool: "leadbay_find_new_leads", + forbidden_tools: ["leadbay_pull_leads", "leadbay_extend_lens"], + }, + { + intent: + "Get me 20 brand-new prospects that look like our best customer, with the VP People's email.", + expected_tool: "leadbay_find_new_leads", + forbidden_tools: ["leadbay_pull_leads", "leadbay_enrich_titles"], + }, + { + intent: "We're entering the Lyon market — find 15 hotels that fit our ICP.", + expected_tool: "leadbay_find_new_leads", + forbidden_tools: ["leadbay_new_lens"], + }, + { + intent: + "Here are 60 restaurant websites from my Austin sweep — which fit our profile, and who's the owner at each?", + expected_tool: "leadbay_qualify_leads", + forbidden_tools: [ + "leadbay_find_new_leads", + "leadbay_bulk_qualify_leads", + "leadbay_import_leads", + ], + }, + { + intent: + "Re-qualify everything you delivered last week and get phone numbers for the good ones.", + expected_tool: "leadbay_qualify_leads", + forbidden_tools: ["leadbay_bulk_qualify_leads"], + }, + { + intent: + "Vet these 12 accounts from my spreadsheet against our qualification criteria.", + expected_tool: "leadbay_qualify_leads", + forbidden_tools: ["leadbay_import_leads", "leadbay_bulk_qualify_leads"], + }, + { + intent: "Any results yet from that lead search job you started?", + expected_tool: "leadbay_lead_job_status", + forbidden_tools: [ + "leadbay_bulk_enrich_status", + "leadbay_import_status", + "leadbay_qualify_status", + ], + }, + { + intent: + "Wait for the lead delivery job to finish and show me everything it found.", + expected_tool: "leadbay_lead_job_status", + forbidden_tools: ["leadbay_bulk_enrich_status"], + }, +]; diff --git a/packages/mcp/test/prompt-release-gate.test.ts b/packages/mcp/test/prompt-release-gate.test.ts new file mode 100644 index 00000000..3cf9d9c7 --- /dev/null +++ b/packages/mcp/test/prompt-release-gate.test.ts @@ -0,0 +1,100 @@ +/** + * The leadbay_new_leads prompt is gated with the tools it drives. + * + * Every step of that guided workflow calls leadbay_find_new_leads / + * leadbay_qualify_leads / leadbay_lead_job_status. With the delivery gate off, + * offering the prompt would start a flow whose every call is missing from + * tools/list — so the prompt hides and reappears with them. + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; + +const FLAG = "LEADBAY_MCP_LEAD_DELIVERY"; +const GATED = "leadbay_new_leads"; + +async function promptNames( + flag: string | undefined, + opts: { includeWrite?: boolean } = {} +) { + vi.resetModules(); + const previous = process.env[FLAG]; + if (flag === undefined) delete process.env[FLAG]; + else process.env[FLAG] = flag; + try { + const mod = await import("../src/prompts.js"); + return { + exposed: mod.listPrompts(opts).map((p) => p.name), + all: mod.listAllPrompts().map((p) => p.name), + get: (name: string) => mod.getPrompt(name, {}, opts), + }; + } finally { + if (previous === undefined) delete process.env[FLAG]; + else process.env[FLAG] = previous; + } +} + +afterEach(() => vi.resetModules()); + +describe("leadbay_new_leads prompt release gate", { timeout: 30_000 }, () => { + it("is hidden by default", async () => { + const { exposed } = await promptNames(undefined); + expect(exposed).not.toContain(GATED); + }); + + it("is exposed when the delivery flag is on", async () => { + const { exposed } = await promptNames("1"); + expect(exposed).toContain(GATED); + }); + + it("stays in the full catalogue for contract audits either way", async () => { + const off = await promptNames(undefined); + const on = await promptNames("1"); + expect(off.all).toContain(GATED); + expect(on.all).toContain(GATED); + }); + + it("gates only that prompt", async () => { + const off = await promptNames(undefined); + const on = await promptNames("1"); + const difference = on.exposed.filter((n) => !off.exposed.includes(n)); + expect(difference).toEqual([GATED]); + }); + + it("stays hidden on a read-only server even with the flag on", async () => { + // Every phase calls write-tier delivery tools, so LEADBAY_MCP_WRITE=0 + // leaves the workflow unrunnable regardless of the rollout flag. + const { exposed } = await promptNames("1", { includeWrite: false }); + expect(exposed).not.toContain(GATED); + }); + + it("prompts/get refuses a gated prompt, not just prompts/list", async () => { + // A cached slash command bypasses the list entirely. The gate reads the + // env at CALL time, so each case must assert while its flag is still set — + // hence the calls live inside the helper rather than on a returned closure. + const call = async ( + flag: string | undefined, + opts: { includeWrite?: boolean } = {} + ) => { + vi.resetModules(); + const previous = process.env[FLAG]; + if (flag === undefined) delete process.env[FLAG]; + else process.env[FLAG] = flag; + try { + const mod = await import("../src/prompts.js"); + try { + mod.getPrompt(GATED, {}, opts); + return null; + } catch (err) { + return String(err); + } + } finally { + if (previous === undefined) delete process.env[FLAG]; + else process.env[FLAG] = previous; + } + }; + + expect(await call(undefined)).toMatch(/not enabled/i); + expect(await call("1", { includeWrite: false })).toMatch(/not enabled/i); + expect(await call("1")).toBeNull(); + }); +}); diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts index 9c2e1292..84712f76 100644 --- a/packages/mcp/vitest.config.ts +++ b/packages/mcp/vitest.config.ts @@ -11,5 +11,20 @@ export default defineConfig({ environment: "node", include: ["test/**/*.test.ts"], exclude: ["test/smoke/**", "node_modules", "dist"], + env: { + // Run the contract audits against the FULL registry. WORKFLOWS.md is a + // map of what Leadbay can do, so `workflows.test.ts` asks "does every + // leadbay_* name resolve to something that exists?" — a question about + // the registry, not about one deployment's exposure. With the flag off, + // the delivery tools are absent from the exported arrays and that audit + // would read documented, registered tools as typos. + // + // Set here rather than by widening the audit's imports: the audit is an + // established test file, and a rollout flag should not be able to make + // WORKFLOWS.md look wrong. Tests that exercise the gate itself + // (prompt-release-gate.test.ts, mcp-first-delivery-gate.test.ts) set and + // delete the variable themselves, so they are unaffected by this default. + LEADBAY_MCP_LEAD_DELIVERY: "1", + }, }, }); diff --git a/packages/promptforge/prompts/release-gated/leadbay_new_leads.md.tmpl b/packages/promptforge/prompts/release-gated/leadbay_new_leads.md.tmpl new file mode 100644 index 00000000..13316260 --- /dev/null +++ b/packages/promptforge/prompts/release-gated/leadbay_new_leads.md.tmpl @@ -0,0 +1,182 @@ +--- +name: leadbay_new_leads +kind: prompt +# Every phase of this workflow calls leadbay_find_new_leads / +# leadbay_qualify_leads / leadbay_lead_job_status, which are gated off until +# the /1.6/mcp/* backend routes ship. Suppresses the Claude SKILL.md (a static +# file with no runtime gate); the MCP prompt is filtered by listPrompts(). +# DELETE this line in the release that un-gates the tools. +release_gated: true +short_description: | + Guided net-new lead delivery — turn a natural-language need ("gyms around + Dallas that would buy our flooring") into ICP-perfect NEW companies with + qualification evidence and the right contact, via leadbay_find_new_leads. + Trigger on "find me new leads", "get me N companies that ", + "we're entering ". Do NOT trigger on "today's leads" + (leadbay_daily_check_in) or "qualify these companies I have" + (leadbay_qualify_leads). +arguments: + - name: need + description: "What the user is looking for, in their own words (e.g. '10 gyms around Dallas that would buy modular flooring, with phone numbers'). Optional — the session starts by asking when absent." + required: false +expected_calls: + - leadbay_find_new_leads + - leadbay_lead_job_status + - leadbay_qualify_leads +failure_modes: + - Passes the user's raw sentence as `query` instead of crafting an example_lead description (vendor-vocabulary trap — measured 0 delivered from a raw query vs on-profile results from a crafted example) + - Invents a distinctive brand name in example_lead.name (pulls matching toward name-lookalikes) + - Puts event language ("hiring", "expanding", "just raised") into the seed description + - Launches qualify:true or channels without a dry_run quote and the user's explicit go-ahead + - Retries a failed/timed-out submit with a NEW request_id (double-spend) — the same ask must reuse the same request_id + - Reports "no results" without narrating the funnel + scope_notes and proposing a concrete fix + - Renders delivered leads as freeform prose instead of the canonical lead-delivery table + - Blends two distinct buyer archetypes into one seed description instead of running one search per archetype + - Passes a country name in filters.locations (silently matches a same-named town — whole-country intent means OMITTING locations) or a nested employees object instead of the flat employees_min/employees_max + - Renders rows that visibly violate the user's exclusions, or presents a best-fit-under-30 table as an answer instead of flagging weak matches +--- + +{{include:headers/agent-memory-preamble}} + +{{include:iron-laws/no-fabrication}} + +{{include:gates/defer-to-tool-rendering}} + +Find net-new leads for me. My need, in my words: + +> {{arg:need}} + +If that need was not supplied to you directly, take it from the message that +started this — the request in my own words is the need, and I should never be +asked to repeat something I already said. Only when BOTH are missing or too +vague to name (a) who I sell to and (b) roughly how many leads I want, ask me +ONCE — one short question — then proceed. Default count when unstated: 10. + +# PHASE 1 — UNDERSTAND THE BUYER (no tool calls yet) + +From my words, work out: +- What I SELL and therefore WHO WRITES ME CHECKS — the buyer category, never + the buyer's customers, never my competitors. If my product helps companies + of type X serve audience Y, my buyer is X. +- Hard constraints: geography, size band, sector, exclusions ("no + franchises", "pas de grands groupes" — negatives BIND). +- Contact needs: do I want a person? Which titles? Email, phone, both? +- Buyer archetypes: if my need genuinely spans two different kinds of buyer, + plan one search per archetype — never one blended seed. + +# PHASE 2 — CRAFT THE SEED + +Compose the `example_lead` for each archetype following the craft rules in +the leadbay_find_new_leads description (registry-style description of a +FICTIONAL typical buyer; no invented brand name; no event language; hard +constraints go in `filters` with the FLAT keys `employees_min`/`employees_max` +and city/state/region `locations` — never a country name). Show me the seed +description(s) in one line each — I should recognize my ideal customer in +them. + +`filters` only encodes sectors, locations and employee bounds. Any constraint +that does not fit those keys — above all EXCLUSIONS like "no franchises" or +"pas de grands groupes" — has nowhere to live in the filter schema, so it must +not be dropped on the floor: express it positively in the seed `description` +(an independent single-site operator rather than "no franchises"), and carry +the exclusion forward yourself to Phase 5, where you drop violating rows and +say you dropped them. Tell me plainly if a constraint can only be enforced +that way — after the fact, not by the search. + +Composing this fictional seed from my words is expected and permitted: it is +the tool's designed input, not fabricated data. What must never be invented is +a RESULT — company names, contacts, scores, or anything presented as coming +back from Leadbay. + +# PHASE 3 — FREE PREVIEW + +Call `leadbay_find_new_leads` with the seed, `filters`, `count`, +`qualify: false`, no channels — this is FREE — and a `request_id` derived +from the ask + the ARCHETYPE + today's date. `count` is the TOTAL I asked +for, not a per-search number: with two archetypes and a request for 10, +split it (5 + 5, or whatever weighting fits my ask) rather than sending 10 +to each — otherwise I get 20 leads and, on the paid pass, pay for 20. + +When you RETRY a search — it timed out, or the job is still live — reuse the +`request_id` you already sent, verbatim. Do not recompute it: rederiving from +"today's date" after midnight yields a new key, the backend cannot dedupe, and +a second paid, novelty-claiming search launches. Roll the date only when I am +genuinely asking for a new batch. The archetype component is not +optional: `request_id` is the idempotency key, so two archetype searches +sharing one id dedupe to the same job and the second archetype is never +searched. Render the delivery table and judge fit honestly: are these the +kind of companies I asked for? + +- **`still_running: true`** → the job is ALIVE. Do not judge the seed and do + not relaunch — poll `leadbay_lead_job_status` (`wait_seconds: 60`) until + it goes terminal, reporting progress. Relaunching now burns an active-job + slot and rate-limit budget on a search that may be about to deliver. +- **On-profile** (terminal) → offer Phase 4. +- **Off-profile or empty** (terminal) → read `funnel` + + `explain.scope_notes`, tell me what went wrong in one line (wrong + archetype? too narrow a filter? thin universe?), reshape the seed or + filters, and retry under a NEW request_id. Reshaping is free; do not pay + to explore a bad seed. + +# PHASE 4 — PAID DEPTH (only with my explicit go-ahead) + +When I want qualification evidence and/or reachable contacts: +1. Quote first: `dry_run: true` on the tool you will actually run, with the + exact flags I asked for, and tell me the worst-case cost in plain money. + The two tools take DIFFERENT flags — passing the wrong one is rejected + outright (`additionalProperties: false`): + - `leadbay_qualify_leads`: `qualify: true`, `contact_titles`, + `title_gate`, `channels`, `max_cost`. **No `min_ai_score`.** + - `leadbay_find_new_leads`: the same, PLUS `min_ai_score` and `count`. +2. On my go-ahead, prefer feeding the free preview's deliveries to + `leadbay_qualify_leads` (`prior_deliveries: {job_id}`) — one paid pass PER + preview job when Phase 3 ran several archetypes, or merge their delivered + refs into a single `lead_refs` call. Never qualify just the first job and + call it done: the other archetypes are part of what I asked for. It only + spends on + companies already known to match. Paid calls need `confirm: true`; without + it the tool withholds the submit and hands back a quote instead of + spending. That applies to `leadbay_find_new_leads` too whenever you set + `qualify: true` or ask for channels. + + If the preview delivered FEWER than I asked for, do both halves and do not + conflate them: qualify what the preview already found, and run the fresh + search only for the SHORTFALL — `count` = what is still missing, never the + original number, under a NEW `request_id`. Reusing the preview's id dedupes + the paid submit back into the free job; keeping the original count buys a + whole second batch, because `novelty: org` already excludes everything the + preview delivered. + + The same arithmetic applies AFTER the paid pass. A full-count preview can + still end short once qualification disqualifies rows or a strict title / + channel match misses: what I asked for is n QUALIFIED, CONTACTABLE leads, + not n examined. Count the delivered-and-callable rows; if they fall short, + tell me the gap in one line and offer to top it up — another shortfall-sized + search under a NEW `request_id`, quoted first like any paid run. Never + silently hand back fewer than I asked for and paid toward. + + Pass the leads already EXAMINED-AND-REJECTED into that top-up's + `exclude_lead_ids` — disqualified and skipped, from both the preview and + the paid pass. `novelty: org` already excludes prior DELIVERIES, so + delivered ids are redundant there; the rejected ones are exactly what it + misses, and without them the top-up re-picks the same misses and charges + again to close no gap. **`exclude_lead_ids` caps at 500** — a wide + `exploration_cap` can examine more than that, so send the most recent 500 + rejects rather than an over-long list the tool refuses outright. +3. While the job runs, poll with `leadbay_lead_job_status` + (`wait_seconds: 60`); report progress, not silence. + +# PHASE 5 — DELIVER + +Before rendering, sanity-check every row: geography inside my fence (drop +and call out same-named-city leaks), descriptions actually matching my ask +(especially when `explain.seed_strategy` is `text_match_exemplars` — fit +scores run hot there), visible violations of my exclusions dropped. If the +best fit is under 30, say "weak matches only" and propose reshaping before +showing more than 3. + +Render per the lead-delivery table, then ALWAYS the funnel line: matched / +examined / qualified / disqualified / delivered / stop reason / spend. Zero +delivered gets a diagnosis and a concrete next move, never a shrug. Close +with NEXT STEPS from the tool description — and STOP; take no further action +without my say-so. diff --git a/packages/promptforge/snippets/heuristics/example-lead-craft.md b/packages/promptforge/snippets/heuristics/example-lead-craft.md new file mode 100644 index 00000000..833c09b3 --- /dev/null +++ b/packages/promptforge/snippets/heuristics/example-lead-craft.md @@ -0,0 +1,39 @@ +### Crafting the `example_lead` seed — the input that decides result quality + +The `example_lead` is a FICTIONAL typical ideal customer, matched against real +registry/website descriptions — which state what a company **IS**, never what +is happening. Write it the same way or the matcher drifts. Every rule below is +measured: + +1. **Describe the BUYER, never the seller.** Ask: "would this company write a + check to my user?" A seed describing what the user SELLS surfaces their + *competitors and vendors*. If the product helps companies of type X serve + customers of type Y, the seed describes X — never Y. +2. **Put everything in `description`; leave `name` unset.** An invented brand + name pulls matching toward name-lookalikes — a seed named "Meridian + Analytics" returned five unrelated "Meridian" companies. +3. **Registry style, one sentence to ~250 chars.** Industry niche, business + model, what they sell or operate, who they serve, observable scale. Write + it like the first paragraph of their About-Us page. + - STRONG: "Operator of full-service fitness centers offering strength + areas, group classes and personal training to members across multiple + clubs." + - WEAK (generic): "A gym in Texas." + - WRONG (seller-side): "Supplier of durable modular flooring for gyms." +4. **No event language.** "hiring", "expanding", "just raised" are not + filters — registry descriptions never contain them, so they dilute the + profile. Purchase triggers belong in the org's qualification questions. +5. **No meta-markers.** Never "(example)", "(fictional)", "(placeholder)". +6. **Hard constraints go in `filters`, not prose — exact keys:** + `sectors: string[]`, `locations: string[]`, `employees_min: number`, + `employees_max: number`. FLAT numbers — nested `employees: {min, max}` + exists only in RESULT payloads. `example_lead.employees` does not filter. + `locations` take city/state/region names ("Dallas, TX", "Île-de-France"); + a country name is refused in code — whole-country intent = omit it. +7. **Prefer `example_lead` over `query`.** Query matches topic *vocabulary*: + "gyms that need durable flooring" surfaced flooring VENDORS, 0 delivered. + Use `query` only for signal an example can't express. +8. **One seed per buyer archetype.** An ask spanning two segments ("gyms and + warehouses") needs one search each with its own description and + `request_id` — a blended seed lands between the clusters and matches + neither. diff --git a/packages/promptforge/snippets/next-steps/find-new-leads.md b/packages/promptforge/snippets/next-steps/find-new-leads.md new file mode 100644 index 00000000..a97ca19a --- /dev/null +++ b/packages/promptforge/snippets/next-steps/find-new-leads.md @@ -0,0 +1,15 @@ +## NEXT STEPS — after a find_new_leads delivery + +{{include:next-steps/ask-user-input-routing}} + +Pick the 2-3 options that match what actually happened — never all seven: + +| Observation | Suggest | Calls | +|---|---|---| +| Job still running (`still_running: true`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) | +| Free run delivered on-profile leads | "Qualify these N against your criteria (paid — `dry_run` first)" | leadbay_qualify_leads(prior_deliveries: {job_id}) | +| Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach | +| Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) | +| Stopped at cost cap (`stop_reason: max_cost`) | "Raise the cap to X and get the remaining N" — X in the account's currency per the funnel-line rule, never a hard-coded `$` | leadbay_find_new_leads, NEW request_id (same-id only dedupes onto a LIVE job) + higher max_cost + `count` = the SHORTFALL (`items_requested` − delivered), not the original + `exclude_lead_ids` = the examined-but-REJECTED ids (novelty covers delivered; these are what it misses — without them the rerun re-buys the same losers) | +| Stopped on org quota (`stop_reason: quota`) | "Wait for the reset, or top up" — never a re-run: it cannot clear an org quota and burns a submit slot to stop in the same place | leadbay_account_status, then leadbay_create_topup_link | +| User wants these tracked in Leadbay | "Add the keepers to a campaign" | leadbay_create_campaign / leadbay_add_leads_to_campaign | diff --git a/packages/promptforge/snippets/next-steps/lead-job-status.md b/packages/promptforge/snippets/next-steps/lead-job-status.md new file mode 100644 index 00000000..2e189408 --- /dev/null +++ b/packages/promptforge/snippets/next-steps/lead-job-status.md @@ -0,0 +1,12 @@ +## NEXT STEPS — after a job status poll + +{{include:next-steps/ask-user-input-routing}} + +Pick the ONE row matching the job's state and offer at most two options — this +is a status tool, keep it terse: + +| Observation | Suggest | Calls | +|---|---|---| +| Still running | "Keep waiting (~1 min) or leave it — results are kept 30 days" | leadbay_lead_job_status(job_id, wait_seconds: 60) | +| Terminal (completed / partial / failed) | Render the delivery per the RENDERING block, then offer the matching find_new_leads / qualify_leads NEXT STEPS | — | +| `expired` (past the 30-day window) | "Re-read the billed leads from your delivery ledger" — there is nothing left to render: the job terminalized and its items are no longer listed, so do NOT present an empty delivery as a result | leadbay_qualify_leads(prior_deliveries: {job_id}) | diff --git a/packages/promptforge/snippets/next-steps/qualify-leads.md b/packages/promptforge/snippets/next-steps/qualify-leads.md new file mode 100644 index 00000000..c2a4c807 --- /dev/null +++ b/packages/promptforge/snippets/next-steps/qualify-leads.md @@ -0,0 +1,14 @@ +## NEXT STEPS — after a qualify_leads delivery + +{{include:next-steps/ask-user-input-routing}} + +Pick the 2-3 options that match what actually happened: + +| Observation | Suggest | Calls | +|---|---|---| +| Job still running | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) | +| Fit leads with contacts delivered | "Draft outreach for the qualified ones" | leadbay_prepare_outreach | +| Items skipped `not_in_universe` | "Import those companies first, then re-qualify" | leadbay_import_leads → leadbay_qualify_leads | +| Items skipped `low_confidence_identity` | "Pick the right match" (show `resolution.alternatives`) | leadbay_qualify_leads with the chosen lead_id | +| Contacts delivered without channels | "Purchase verified emails/phones for the keepers (state cost first)" | leadbay_qualify_leads(lead_refs with contact_id, channels) | +| Disqualified with evidence | "Review why — adjust qualification questions if the criteria are off" | leadbay_get_qualification_questions | diff --git a/packages/promptforge/snippets/rendering/disqualified-delivered.md b/packages/promptforge/snippets/rendering/disqualified-delivered.md new file mode 100644 index 00000000..3c17408d --- /dev/null +++ b/packages/promptforge/snippets/rendering/disqualified-delivered.md @@ -0,0 +1,14 @@ +**Delivered ≠ endorsed.** This tool DELIVERS org-owned companies that FAILED +qualification, carrying their negative evidence — so a delivered item is not +automatically a prospect. An item whose `status_reason` is `disqualified`, or +whose `fit.components.qualification` is available with a negative `ai_score`, +must NOT go in the fit table: its firmographic score can still be high, and a +full bar beside "why it fits" reads as a recommendation to call an account the +evaluation just rejected. + +Give those their own short section after the fit table, titled +**Evaluated — does not fit**: linked company, then the verdict in plain +words from the +qualification evidence (failed question verdicts, missed tags, IBP reasoning). +That is the deliverable — "here's why to skip this account" — not a defect to +hide. diff --git a/packages/promptforge/snippets/rendering/lead-delivery-table.md b/packages/promptforge/snippets/rendering/lead-delivery-table.md new file mode 100644 index 00000000..08358aad --- /dev/null +++ b/packages/promptforge/snippets/rendering/lead-delivery-table.md @@ -0,0 +1,93 @@ +## RENDERING — delivery table + honest funnel line + +Render delivered leads (`leads[]`, i.e. items with status `delivered` or +`degraded`) as a markdown table **in the order returned**. Exactly three +columns. Then ALWAYS close with the funnel line (below) — even, especially, +when nothing was delivered. + +**Column 1 — Company** + +- Line 1: 10-segment fit bar in inline-code backticks from `lead.fit.score` + (0-100): `filled = round(score/10)`, glyphs `▰` filled / `▱` empty. When + `lead.fit.components.qualification.available` is true AND `ai_score > 0`, + replace the LAST filled segment with `❖` (AI-confirmed cap). When + `fit.available` is false, render `▱▱▱▱▱▱▱▱▱▱` and say "unscored" in col 2. + Never print the numeric score. +- Insert `
`, then: linked company name (target `company.website`, bare + hostnames get `https://`; unlinked plain text when absent) + ` · ` + short + location (City, ST / City, Country) + ` · ` + employees as `min–max` (omit + when `employees.known` is false). + +**Column 2 — Why it fits** + +- One sentence ≤ 20 words. Priority: `fit.reasoning` → gist of + `company.description` → top `fit.components.qualification.matched_tags`. +- If the item status is `degraded` or a requested channel failed, append the + honest flag in italics, e.g. *(email could not be sourced)*. + +**Column 3 — Contact** + +- `[Name](linkedin) · role` (linked name mandatory when a LinkedIn URL + exists; plain name otherwise). Below it, the PURCHASED channels only: + `✉ value` / `☎ value` inline as plain text (they auto-linkify). +- Channel statuses: `delivered` → show value; `already_owned` → value + + *(already yours)*; `masked` → "on file — reveal via channels"; + `not_requested` → omit; `failed_*` → *(no verified email/phone)*. +- No contact on the item (`contact` null): render `—` (title_gate `prefer` + delivers such rows flagged; say so in col 2 only when contact_titles were + requested). + +**The funnel line (mandatory, after the table):** + +One short line narrating the delivery honestly, from `funnel` + `cost` + +`explain.scope_notes`: + +> Matched N · examined E · qualified Q · disqualified D → **delivered X of +> the Y asked** · stopped: · spent C.CC. + +**Money: divide, then symbol.** Every amount (`cost.spent`, +`estimated_cost.max`, quotes) is `cost_cents` — divide by 100, two decimals, +so `165` renders `1.65`, NEVER `165.00`. Symbol from the account region: US +`$`, France `€`, unknown → bare. Never hard-code `$`: it misstates a charge. + +"of the Y asked" needs `summary.items_requested`, which submits carry but a +later `leadbay_lead_job_status` snapshot does not. Without it write **delivered +X** and stop — never back-fill Y from `matched`/`examined` (they count +candidates), never guess it. + +Plain-word stop reasons: `target_reached` → omit (success), `pool_exhausted` → +"ran out of matching candidates", `max_cost` → "hit the cost cap", `quota` → +"hit an org quota", `time_budget` → "hit the 30-min time budget". + +**When `delivered` is 0**: NEVER say just "no results". Render no table; give +the funnel line plus the relevant `explain.scope_notes` (the backend's own +diagnosis), then propose the concrete fix (reshape the seed per the craft +rules, lower `min_ai_score`, raise `max_cost`, drop a filter) as NEXT STEPS. + +**Weak batch**: when the BEST delivered `fit.score` is under 30, don't present +the table as an answer — open with "weak matches only", show at most the top 3, +propose reshaping the seed/filters first. The count was filled with +barely-better-than-random candidates. + +**Sanity-check every row**: (a) geo — `city`/`region` must sit inside any +requested fence; drop and call out leaks (same-named cities slip through). +(b) When `explain.seed_strategy` is `text_match_exemplars` (the standard FR +path), fit is calibrated for lead-to-lead distances, not exemplar centroids — +treat high scores skeptically and verify each row's `description`. + +**Skipped items** (`skipped[]`, qualify jobs mostly): render a compact second +table `Ref → Outcome` translating `status_reason` to plain words: +`not_in_universe` → "not in the Leadbay universe (import it first)", +`low_confidence_identity` → "couldn't safely match — check `resolution.alternatives`", +`no_matching_contact` → "no contact with the requested title", +`disqualified` → "evaluated: does not fit" (evidence is in the item when owned), +`enrichment_failed` → "channel could not be sourced (not billed)". + +**`items_truncated`**: rows are a PREFIX, not the batch. Say so, and offer +`leadbay_lead_job_status(job_id, since: next_since)` for the rest. + +**Hide from the user:** UUIDs (keep for tool calls, never render), cursors, +`explain.model`/`intelligence_snapshot`, raw `distance`/`calibration`, +`seq`/`from_cache`, empty arrays. + +{{include:linking/contact-linkedin}} diff --git a/packages/promptforge/src/assembler.ts b/packages/promptforge/src/assembler.ts index f17b5d35..3417889a 100644 --- a/packages/promptforge/src/assembler.ts +++ b/packages/promptforge/src/assembler.ts @@ -24,7 +24,11 @@ function emitRoutingBlock( } if (routing.anti_triggers && routing.anti_triggers.length > 0) { const formatted = routing.anti_triggers - .map((a) => `"${a.phrase}" → \`${a.route_to}\``) + .map( + (a) => + `"${a.phrase}" → \`${a.route_to}\`` + + (a.gated ? " (only if listed)" : "") + ) .join("; "); lines.push(`Do NOT use for: ${formatted}.`); } diff --git a/packages/promptforge/src/cli.ts b/packages/promptforge/src/cli.ts index 7bbddebb..ec450b8c 100644 --- a/packages/promptforge/src/cli.ts +++ b/packages/promptforge/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { assemble, type AssembleResult } from "./assembler.js"; import { emit, emitServerInstructions, diff, writeIfDifferent } from "./emit.js"; import { buildSkillFiles, type SkillFile } from "./skills.js"; @@ -51,6 +51,11 @@ interface AssembleAndEmitOutput { promptsModule: string; toolDescriptionsModule: string; skillFiles: SkillFile[]; + // Prompts marked release_gated emit NO SKILL.md. Their paths are returned so + // the build can DELETE a previously-emitted skill: writeIfDifferent only + // writes, so a prompt gated after the fact would otherwise leave a static, + // auto-triggering skill on disk to be committed and shipped. + gatedSkillPaths: string[]; result: AssembleResult; } @@ -59,11 +64,15 @@ function runAssemble(): AssembleAndEmitOutput { const result = assemble({ root: PKG_ROOT, registeredToolNames: registered }); const { promptsModule, toolDescriptionsModule } = emit(result); const skillFiles = buildSkillFiles(result.prompts); - return { promptsModule, toolDescriptionsModule, skillFiles, result }; + const gatedSkillPaths = result.prompts + .filter((p) => p.frontmatter.release_gated === true) + .map((p) => join(SKILLS_OUT_DIR, p.frontmatter.name, "SKILL.md")); + return { promptsModule, toolDescriptionsModule, skillFiles, gatedSkillPaths, result }; } function cmdBuild(): void { - const { promptsModule, toolDescriptionsModule, skillFiles } = runAssemble(); + const { promptsModule, toolDescriptionsModule, skillFiles, gatedSkillPaths } = + runAssemble(); const serverInstructionsModule = emitServerInstructions(SERVER_INSTRUCTIONS_SNIPPETS); const r1 = writeIfDifferent(PROMPTS_OUT, promptsModule); const r2 = writeIfDifferent(TOOL_DESC_OUT, toolDescriptionsModule); @@ -78,10 +87,22 @@ function cmdBuild(): void { `[forge] ${fullPath.replace(REPO_ROOT + "/", "")}: ${r.changed ? "wrote" : "unchanged"}`, ); } + // A skill is a static file that auto-triggers with no runtime gate, so a + // prompt marked release_gated must not leave one behind. writeIfDifferent + // only ever writes, so deletion has to be explicit. + for (const path of gatedSkillPaths) { + if (existsSync(path)) { + rmSync(dirname(path), { recursive: true, force: true }); + console.log( + `[forge] ${path.replace(REPO_ROOT + "/", "")}: removed (release_gated)`, + ); + } + } } function cmdCheck(): void { - const { promptsModule, toolDescriptionsModule, skillFiles } = runAssemble(); + const { promptsModule, toolDescriptionsModule, skillFiles, gatedSkillPaths } = + runAssemble(); const serverInstructionsModule = emitServerInstructions(SERVER_INSTRUCTIONS_SNIPPETS); const d1 = diff(PROMPTS_OUT, promptsModule); const d2 = diff(TOOL_DESC_OUT, toolDescriptionsModule); @@ -91,13 +112,27 @@ function cmdCheck(): void { const fullPath = join(SKILLS_OUT_DIR, skill.relativePath); if (!diff(fullPath, skill.content).matches) staleSkills.push(fullPath); } - if (!d1.matches || !d2.matches || !d3.matches || staleSkills.length > 0) { + // A gated prompt must ship NO skill — a leftover file would auto-trigger a + // workflow whose tools are hidden, so fail rather than let it be committed. + const orphanedGated = gatedSkillPaths.filter((p) => existsSync(p)); + if ( + !d1.matches || + !d2.matches || + !d3.matches || + staleSkills.length > 0 || + orphanedGated.length > 0 + ) { if (!d1.matches) console.error(`[forge] ${PROMPTS_OUT} is stale. Run pnpm prompts:build.`); if (!d2.matches) console.error(`[forge] ${TOOL_DESC_OUT} is stale. Run pnpm prompts:build.`); if (!d3.matches) console.error(`[forge] ${SERVER_INSTRUCTIONS_OUT} is stale. Run pnpm prompts:build.`); for (const path of staleSkills) { console.error(`[forge] ${path} is stale. Run pnpm prompts:build.`); } + for (const path of orphanedGated) { + console.error( + `[forge] ${path} belongs to a release_gated prompt and must not ship. Run pnpm prompts:build.`, + ); + } process.exit(1); } console.log("[forge] generated files are up-to-date."); diff --git a/packages/promptforge/src/frontmatter.ts b/packages/promptforge/src/frontmatter.ts index f30d8790..a7f10a9a 100644 --- a/packages/promptforge/src/frontmatter.ts +++ b/packages/promptforge/src/frontmatter.ts @@ -25,6 +25,12 @@ export const RoutingAntiTriggerSchema = z.object({ route_to: z.string().regex(/^leadbay_[a-z0-9_]+$/, { message: "route_to must be a leadbay_* tool name", }), + // The target is release-gated, so it may be absent from tools/list on a + // default deployment. Marked HERE rather than explained in the body: the + // routing block is the part every host loads even when truncating, and a + // caveat further down the description is never read by the host that most + // needs it. + gated: z.boolean().optional(), }); // Full-sentence examples — community best practice (Anthropic @@ -73,6 +79,12 @@ export const FrontmatterSchema = z.object({ // with routing, "disabled" otherwise; explicit disabled is for tools whose // first-600-char window cannot carry the shared pointer. memory_protocol: z.enum(["enabled", "disabled"]).optional(), + // Set while a prompt's whole workflow depends on tools that are themselves + // gated off (e.g. a backend route that hasn't shipped). The MCP prompt is + // filtered at runtime, but a Claude SKILL.md is a static file with no + // runtime gate — so promptforge simply does not emit one. Delete this line + // in the release that un-gates the tools. + release_gated: z.boolean().optional(), // Compact rendering recipe (1–3 sentences). Promptforge auto-emits // a `## RENDER (quick)` block. The detailed RENDERING block stays // in the body via {{include:rendering/...}}. diff --git a/packages/promptforge/src/skills.ts b/packages/promptforge/src/skills.ts index acb7777b..8832e8ae 100644 --- a/packages/promptforge/src/skills.ts +++ b/packages/promptforge/src/skills.ts @@ -138,9 +138,15 @@ export interface SkillFile { * comparing against disk (cmdCheck) or writing (cmdBuild). */ export function buildSkillFiles(artifacts: AssembledArtifact[]): SkillFile[] { - return artifacts.map((a) => ({ - name: a.frontmatter.name, - relativePath: `${a.frontmatter.name}/SKILL.md`, - content: buildSkillMarkdown(a), - })); + return artifacts + // A SKILL.md is a static file that auto-triggers with no runtime gate, so + // a release_gated prompt must not ship one at all — otherwise Claude Code + // enters a guided workflow whose first tool call is missing from + // tools/list. The MCP prompt is filtered at runtime instead. + .filter((a) => a.frontmatter.release_gated !== true) + .map((a) => ({ + name: a.frontmatter.name, + relativePath: `${a.frontmatter.name}/SKILL.md`, + content: buildSkillMarkdown(a), + })); } diff --git a/packages/promptforge/test/skills-release-gate.test.ts b/packages/promptforge/test/skills-release-gate.test.ts new file mode 100644 index 00000000..7b3878be --- /dev/null +++ b/packages/promptforge/test/skills-release-gate.test.ts @@ -0,0 +1,96 @@ +/** + * Release-gated prompts emit no SKILL.md. + * + * A Claude skill is a static file that auto-triggers on matching requests with + * no runtime switch, so shipping one while its tools are gated off starts a + * workflow that fails on the first tool call. The MCP prompt is filtered at + * runtime instead; the skill simply must not exist on disk. + */ + +import { describe, it, expect } from "vitest"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { assemble } from "../src/assembler.js"; +import { buildSkillFiles } from "../src/skills.js"; +import { discoverRegisteredTools } from "../src/registry.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = resolve(__dirname, ".."); +const REPO_ROOT = resolve(PKG_ROOT, "..", ".."); +const CORE_SRC = resolve(REPO_ROOT, "packages", "core", "src"); +const PROMPTS_DIR = resolve(PKG_ROOT, "prompts"); +const SKILLS_DIR = resolve( + REPO_ROOT, + ".claude-plugin", + "plugins", + "leadbay", + "skills", +); + +const registered = discoverRegisteredTools(CORE_SRC); +const result = assemble({ root: PKG_ROOT, registeredToolNames: registered }); +const gated = result.prompts.filter( + (p) => p.frontmatter.release_gated === true, +); + +describe("audit: release-gated prompts ship no skill", () => { + it("leadbay_new_leads is currently gated", () => { + // Guards the fixture: if this prompt is un-gated at release, this test + // should be deleted along with the frontmatter flag — not left passing + // vacuously over an empty set. + expect(gated.map((p) => p.frontmatter.name)).toContain("leadbay_new_leads"); + }); + + it("buildSkillFiles emits nothing for a gated prompt", () => { + const emitted = buildSkillFiles(result.prompts).map((s) => s.name); + for (const p of gated) { + expect(emitted).not.toContain(p.frontmatter.name); + } + }); + + it("no gated SKILL.md exists on disk", () => { + for (const p of gated) { + const path = join(SKILLS_DIR, p.frontmatter.name, "SKILL.md"); + expect(existsSync(path), `${path} must not ship while gated`).toBe(false); + } + }); + + // The layout is what lets skills.test.ts stay untouched. That audit asserts + // every .md.tmpl sitting DIRECTLY in prompts/ has a matching SKILL.md, and + // reads the directory non-recursively. A gated prompt has no SKILL.md by + // design, so the only honest way to satisfy that audit is to keep the + // template out of the flat directory — not to weaken the audit's assertion. + // assemble()'s findTemplates() recurses, so the subdirectory still builds. + it("a gated prompt template lives in a subdirectory, not flat in prompts/", () => { + const flat = new Set( + readdirSync(PROMPTS_DIR) + .filter((f) => f.endsWith(".md.tmpl")) + .map((f) => f.replace(/\.md\.tmpl$/, "")), + ); + for (const p of gated) { + const rel = p.sourcePath.slice(p.sourcePath.indexOf("prompts/")); + expect( + flat.has(p.frontmatter.name), + `${p.frontmatter.name} is flat in prompts/ but ships no SKILL.md — ` + + `skills.test.ts would fail. Keep it under prompts/release-gated/.`, + ).toBe(false); + expect(rel.startsWith("prompts/release-gated/")).toBe(true); + } + }); + + // Moving the template out of the flat directory also moves it out of reach of + // assembler.test.ts's B23 audit, which readdirs prompts/ the same shallow way. + // That rule still applies to this prompt — it orchestrates composites that + // ship their own RENDERING block — so re-assert it here rather than let the + // relocation quietly drop the coverage. + it("a gated prompt still carries the defer-to-tool-rendering gate (B23)", () => { + for (const p of gated) { + const source = readFileSync(p.sourcePath, "utf8"); + expect( + source.includes("{{include:gates/defer-to-tool-rendering}}"), + `${p.frontmatter.name} orchestrates rendering composites and must defer to their RENDERING blocks`, + ).toBe(true); + } + }); +}); diff --git a/packages/promptforge/tool-descriptions/composite/bulk-qualify-leads.md.tmpl b/packages/promptforge/tool-descriptions/composite/bulk-qualify-leads.md.tmpl index 4fb28171..51f91f7f 100644 --- a/packages/promptforge/tool-descriptions/composite/bulk-qualify-leads.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/bulk-qualify-leads.md.tmpl @@ -19,7 +19,7 @@ Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads be {{include:headers/tool-when-to-use}} when the user wants more qualified leads than what's currently shown, or when a lead looks promising in leadbay_pull_leads but has an empty `qualification_summary`. -{{include:headers/tool-when-not-to-use}} to qualify a single specific lead — that's leadbay_qualify_lead (granular, advanced). +{{include:headers/tool-when-not-to-use}} to qualify a single specific lead — that's leadbay_qualify_lead (granular, advanced). And NOT for companies the user names or lists themselves (CRM rows, websites, prior deliveries) — that's leadbay_qualify_leads (only if listed — it is release-gated; server-side batch with per-item verdicts and contact matching); this tool only walks the ACTIVE LENS top-down. {{include:headers/verification-required-if-write}} diff --git a/packages/promptforge/tool-descriptions/composite/extend-lens.md.tmpl b/packages/promptforge/tool-descriptions/composite/extend-lens.md.tmpl index 91cec830..29c577e9 100644 --- a/packages/promptforge/tool-descriptions/composite/extend-lens.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/extend-lens.md.tmpl @@ -18,6 +18,9 @@ routing: anti_triggers: - phrase: "show me today's leads" route_to: leadbay_pull_leads + - phrase: "find me companies that " + route_to: leadbay_find_new_leads + gated: true - phrase: "narrow the audience" route_to: leadbay_adjust_audience - phrase: "stop showing me X" diff --git a/packages/promptforge/tool-descriptions/composite/find-new-leads.md.tmpl b/packages/promptforge/tool-descriptions/composite/find-new-leads.md.tmpl new file mode 100644 index 00000000..f80ae2b9 --- /dev/null +++ b/packages/promptforge/tool-descriptions/composite/find-new-leads.md.tmpl @@ -0,0 +1,106 @@ +--- +name: leadbay_find_new_leads +kind: tool-description +short_description: | + One ask -> n NET-NEW companies matching an ICP the user describes, scored + for fit, optionally AI-qualified with evidence, optionally with the right + contact + verified email/phone. Use when the user describes WHO they want + ("find me 10 gyms around Dallas that would buy X"). Don't use it for + today's lens batch (leadbay_pull_leads) or for vetting companies the user + already has (leadbay_qualify_leads). +routing: + triggers: + - "find me new leads" + - "find me N companies that " + - "get me new prospects like " + - "I need leads in that " + - "search for companies that would buy " + - "net-new leads outside my current pipeline" + - "we're entering — who should we target" + anti_triggers: + - phrase: "show me today's leads / what's new today" + route_to: leadbay_pull_leads + - phrase: "more leads like the ones in my lens" + route_to: leadbay_extend_lens + - phrase: "qualify / vet these companies I have" + route_to: leadbay_qualify_leads + - phrase: "qualify the top N of my batch" + route_to: leadbay_bulk_qualify_leads + - phrase: "leads I should follow up with" + route_to: leadbay_pull_followups + - phrase: "tell me about " + route_to: leadbay_research_lead_by_name_fuzzy + prefer_when: >- + the user describes a target profile or names a count of NEW companies — + craft the example_lead per the seed rules below BEFORE calling; never + pass the user's raw sentence as query. + examples: + positive: + - "Find me 10 gyms around Dallas that would buy our flooring, with someone I can call." + - "Get me 20 new US SaaS companies, 50-2000 employees, with the VP People's email." + - "We're launching in Lyon — find 15 hotels that fit our ICP." + negative: + - "Show me today's leads." + - "Which leads should I follow up with this week?" + - "Qualify these 40 websites from my spreadsheet." +rendering_hint: | + 3-col table of delivered leads in returned order: col 1 = 10-segment fit + bar + linked company · location · size; col 2 = why-fits ≤20 words; col 3 + = contact + purchased channels. ALWAYS close with the honest funnel line + (matched/examined/delivered/stop reason/spend) — especially on 0 + delivered. Full algorithm below. +next_steps: find-new-leads +annotations: + readOnlyHint: false + destructiveHint: false + idempotentHint: true + openWorldHint: true +--- +Submit a net-new lead search: the backend matches an ICP seed against the full +company universe, applies hard filters, skips what the org already knows +(`novelty: org`), optionally qualifies against the org's own intelligence +(questions, tags, ideal buyer profile — frozen at submit), and optionally buys +contact channels. Polls up to `wait_seconds` (default 45); a longer job returns +`still_running` + `next_poll` — hand off to `leadbay_lead_job_status`. Jobs run +≤30 min, results kept 30 days. + +**Free vs paid — never spend silently.** Default (`qualify: false`, +`channels: []`) is FREE: company profile + fit score + cached research + +contact identity. Paid: `qualify: true` (~94 cost_cents per candidate +EXAMINED, capped by `exploration_cap`/`max_cost`) and `channels` (email 25c / +phone 250c, success-only). Enforced in code: a paid call is WITHHELD unless it +carries `confirm: true` — nothing is submitted and you get +`mode: "needs_confirmation"` with a real quote to show the user. Re-call with +`confirm: true` on their go-ahead ("spend / get their emails" counts). +`confirm: false` vetoes. Free needs no consent. **Preview free first** — +reshaping an off-profile seed is free, exploring it with `qualify: true` is +not. + +**Ad-hoc exclusions ("no chains") are enforced by NO tier** — `filters` has no +exclusion key, and `qualify` scores against the org's FROZEN questions and IBP, +which need not mention chains; the seed's inverse only shifts ranking. +Violators can survive, be paid for and be delivered — post-filter them yourself +and say the tier didn't enforce it. Durable enforcement → +`leadbay_refine_prompt`. + +{{include:heuristics/example-lead-craft}} + +**Parameter notes** +- `request_id` (REQUIRED) is the retry contract: SAME value retrying the same + ask (same live job, no double spend); NEW for a changed ask. Derive from ask + + archetype + date: `gyms-dallas-2026-07-28`. +- Never lower `min_ai_score` together with `channels` — that buys emails for + leads the AI just scored as junk. +- `count` ≤ 50; ≤3 active jobs/org; ≤10 submits/hour (429 + Retry-After — + wait, don't hammer). + +**Read the result honestly** — `funnel` + `explain.scope_notes` tell the story; +zero delivered gets a cause and a next move (rules in RENDERING). + +--- + +{{include:rendering/lead-delivery-table}} + +--- + +{{include:next-steps/find-new-leads}} diff --git a/packages/promptforge/tool-descriptions/composite/lead-job-status.md.tmpl b/packages/promptforge/tool-descriptions/composite/lead-job-status.md.tmpl new file mode 100644 index 00000000..01498112 --- /dev/null +++ b/packages/promptforge/tool-descriptions/composite/lead-job-status.md.tmpl @@ -0,0 +1,66 @@ +--- +name: leadbay_lead_job_status +kind: tool-description +short_description: | + Poll a running leadbay_find_new_leads / leadbay_qualify_leads job by + job_id — cumulative results, funnel, spend. Use when a delivery tool + returned still_running:true or the user asks "is it done / any results + yet". Don't use it for enrichment jobs (leadbay_bulk_enrich_status) or + imports (leadbay_import_status). +routing: + triggers: + - "is the lead search done" + - "any results yet on that job" + - "check on the delivery" + anti_triggers: + - phrase: "is the enrichment done" + route_to: leadbay_bulk_enrich_status + - phrase: "is my import done" + route_to: leadbay_import_status + - phrase: "is the top-N qualification done" + route_to: leadbay_qualify_status + prefer_when: >- + a find_new_leads / qualify_leads result carried next_poll — pass its + job_id; use wait_seconds ~60 when the user asked to wait for results. + examples: + positive: + - "Any leads yet from that search you started?" + - "Wait for the qualification job to finish and show me everything." + negative: + - "Is the email enrichment finished?" + - "Is my CSV import done?" +rendering_hint: | + Terminal job -> render the full delivery per the lead-delivery table + + honest funnel line. Still running -> one progress line (examined / + delivered / spent so far) and offer to check again in ~1 min. Never + render UUIDs or cursors. +next_steps: lead-job-status +annotations: + readOnlyHint: true + destructiveHint: false + idempotentHint: true + openWorldHint: true +--- +Cumulative snapshot of a lead-delivery job: state, funnel counters, every +item emitted so far (full lead payloads for delivered/degraded, honest +status_reason for skipped), spend + breakdown, and the `explain` block +(basis, seed strategy, scope notes). Items are immutable once emitted — +polling never re-reads live data, so numbers only ever grow. + +`wait_seconds: 0` (default) answers instantly; set ~60 to block-wait for +completion when the user asked for results "in this reply". `since` (from a +prior poll's `next_since`) pages only the new items. Jobs terminalize +server-side: past the 30-min wall clock a job reads `completed_partial` +(time budget), past 30 days `expired` (items no longer listed — re-read +billed leads via leadbay_qualify_leads `prior_deliveries`). A 404 means +unknown job or another org's job. + +--- + +{{include:rendering/lead-delivery-table}} + +{{include:rendering/disqualified-delivered}} + +--- + +{{include:next-steps/lead-job-status}} diff --git a/packages/promptforge/tool-descriptions/composite/pull-leads.md.tmpl b/packages/promptforge/tool-descriptions/composite/pull-leads.md.tmpl index 28711a3e..1ef95794 100644 --- a/packages/promptforge/tool-descriptions/composite/pull-leads.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/pull-leads.md.tmpl @@ -21,6 +21,12 @@ routing: # intercepting legitimate discovery intent. E.g. "reach out to new leads" should # still fire pull_leads; only "leads I should reach out to" (existing pipeline) → pull_followups. anti_triggers: + - phrase: "find me N companies that " + route_to: leadbay_find_new_leads + gated: true + - phrase: "new prospects like with their emails" + route_to: leadbay_find_new_leads + gated: true - phrase: "leads I should follow up with" route_to: leadbay_pull_followups - phrase: "I'm going to " @@ -49,6 +55,7 @@ routing: - "Show me today's leads." - "Pull my best new prospects." negative: + - "Find me 10 gyms around Dallas that would buy our flooring." # → leadbay_find_new_leads (custom net-new ask, not the lens batch) - "Which leads should I follow up with this week?" - "I'm flying to Berlin Thursday — who should I meet?" # → leadbay_tour_plan (geographic visit, wants a map) - "I'm in San Francisco next Tuesday — who's worth meeting?" # → leadbay_tour_plan diff --git a/packages/promptforge/tool-descriptions/composite/qualify-leads.md.tmpl b/packages/promptforge/tool-descriptions/composite/qualify-leads.md.tmpl new file mode 100644 index 00000000..01658ca2 --- /dev/null +++ b/packages/promptforge/tool-descriptions/composite/qualify-leads.md.tmpl @@ -0,0 +1,105 @@ +--- +name: leadbay_qualify_leads +kind: tool-description +short_description: | + Qualify companies the user ALREADY has — CRM rows, spreadsheet websites, + prior deliveries — and get the right contact on each, in one server-side + batch (up to 500). Per-item verdicts with evidence; a bad ref never fails + the job. Use when the user names/lists the companies. Don't use it to + FIND new companies (leadbay_find_new_leads) or for the lens top-N + (leadbay_bulk_qualify_leads). +routing: + triggers: + - "qualify these companies" + - "vet this list" + - "which of these fit our ICP" + - "score these websites / accounts" + - "get me the right contact at these companies" + - "re-qualify what you delivered last week" + anti_triggers: + - phrase: "find me new leads / companies that " + route_to: leadbay_find_new_leads + - phrase: "qualify the top N of my lens batch" + route_to: leadbay_bulk_qualify_leads + - phrase: "import this CSV file" + route_to: leadbay_import_leads + - phrase: "tell me about in depth" + route_to: leadbay_research_lead_by_name_fuzzy + - phrase: "add emails to the contacts I selected" + route_to: leadbay_enrich_titles + prefer_when: >- + the user points at SPECIFIC companies (ids, websites, names, a pasted + list, "what you found yesterday") and wants fit verdicts and/or the + right person to talk to. + examples: + positive: + - "Here are 60 restaurant websites from my Austin sweep — which fit, and who's the owner?" + - "Re-qualify last week's delivery and get phone numbers for the good ones." + - "Vet these 12 accounts from my spreadsheet against our criteria." + negative: + - "Find me 10 new gyms in Texas." + - "Qualify the top 10 leads in my batch." + - "I have a CSV of 400 attendees to import." +rendering_hint: | + 3-col table for delivered items (fit bar + company / why-fits ≤20 words / + contact + channels) in returned order, then a compact Ref → Outcome table + for skipped refs (not_in_universe, low_confidence_identity, ... in plain + words), then the honest funnel + cost line. Full algorithm below. +next_steps: qualify-leads +annotations: + readOnlyHint: false + destructiveHint: false + idempotentHint: false + openWorldHint: true +--- +Submit a qualify batch over companies the org already has (or that exist in +the Leadbay universe): each ref is resolved to a known company, freshly +researched + AI-qualified against the org's questions / tags / ideal buyer +profile (frozen at submit), matched to the requested contact titles, and — +when asked — enriched with verified channels. Answers arrive per-item from a +job; this tool polls up to `wait_seconds` (default 45) and hands off to +`leadbay_lead_job_status` when the batch needs longer. + +**Refs are flexible; outcomes are per-item.** `lead_refs` accepts any mix of +`lead_id`, `website`, `name`(+`location`), or a stable `contact_id` from a +prior result (enrichment then targets exactly that person, never a re-match). +`prior_deliveries` expands past MCP deliveries into refs — billed leads stay +re-readable this way even after the 30-day result window. Duplicates collapse. +A ref that can't be served comes back `skipped` with an honest +`status_reason` (`not_in_universe`, `low_confidence_identity` with the +`resolution.alternatives` to choose from, `no_matching_contact`, ...) — that +is an ANSWER about the ref, not an error, and it costs nothing. + +**Disqualified ≠ dropped.** Companies the org owns that fail qualification +are DELIVERED with their negative evidence (question verdicts, tag misses, +IBP reasoning) — "here's why to skip this account" is a deliverable. + +**Cost — never spend silently.** Resolution and identity are free. +`qualify: true` (the default) costs ~94 cost_cents per lead needing FRESH +research+scoring — but repeat calls reuse every fresh cached stage +(`from_cache` flags on the items) and converge to near-zero cost. `channels` +purchase verified email (25c) / phone (250c) on success only; +`already_owned` values cost nothing. + +The gate is enforced in code, not just here: a PAID call (`qualify` left at +its default or set true, and/or any `channels`) is WITHHELD unless it carries +`confirm: true`. Without it the tool submits nothing and returns +`mode: "needs_confirmation"` with a real backend quote — show that quote to +the user, get the go-ahead (an explicit "spend / get their emails" in their +message counts), then re-call with `confirm: true`. `confirm: false` is a +veto: nothing is submitted and no quote round-trip is made. A fully FREE +call (`qualify: false`, no `channels`) needs no `confirm` and passes straight +through. Set `request_id` and reuse it on retries of the same batch. + +**Limits**: 500 refs/job, 3 active jobs/org, 10 submits/hour (429 + +Retry-After beyond — wait, don't hammer), 30-min job wall clock. + +--- + +{{include:rendering/lead-delivery-table}} + +{{include:rendering/disqualified-delivered}} + +--- + +{{include:next-steps/qualify-leads}}