Skip to content
/ solutions / vertex-ai-lead-intake-for-amazon-sp-api-smb-seller-acquisition Vertex AI Lead Intake for Amazon SP-API SMB Seller Acquisition A conversational agent that qualifies prospective Amazon sellers, collects required documents, and registers them via SP-API – replacing static forms.
The problem SMBs wanting to sell on Amazon face a cumbersome registration process; filling forms manually causes drop‑offs. An AI‑driven chat that guides sellers and directly calls SP-API would improve conversion.
Example artifact A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
207 kB · 141 tests· 99.0% coverage· vitest passing
SHA-256 4463bb66f49c1697c0f42ec28e4e01c0c97d06685c5188d36abaea6bdb9c0a75 Comments Sign in to commentSign in with GitHub to comment and vote.
Las Vegas, Nevada USA © 2026 REAA Technologies Inc. — Open-Source AI Solutions for Small Business.
On this page Intro
This recipe builds a conversational AI agent that qualifies prospective Amazon sellers, collects required business documents, and registers them through Amazon’s Selling Partner API (SP-API). Instead of navigating static multi-page forms, sellers complete registration through a natural-language chat interface powered by Vertex AI Gemini. The agent uses budget-aware LLM orchestration, automatic payload repair, circuit-breaker resilience, and Langfuse telemetry.
You’ll build the full stack: a Vertex AI Gemini client, a lead qualification agent with multi-turn retry, an SP-API client with LWA OAuth authentication, a budget tracker with soft/hard caps, a structured payload repair layer, an agent handoff protocol, and two Next.js App Router API routes that wire everything together.
Prerequisites
Node.js 22+ and pnpm (npm install -g pnpm)
A Google Cloud Project with the Vertex AI API enabled and billing set up. See the Vertex AI setup guide .
An Amazon SP-API developer account with LWA (Login with Amazon) credentials. See the SP-API Developer Guide .
A Langfuse account (free tier works) for LLM telemetry — optional, the app degrades gracefully without it.
An Unstructured.io API key for document parsing — optional, used only by the DocumentParser.
Familiarity with TypeScript , Next.js App Router , and basic Zod schema validation.
Step 1: Scaffold the Next.js project
Create a new Next.js project with the App Router and install all dependencies:
pnpm create next-app vertex-ai-lead-intake --typescript --app --src-dir --use-pnpm
cd vertex-ai-lead-intake Next, open package.json and replace its dependencies with the exact-pinned versions you’ll need:
{
"name" : "vertex-ai-lead-intake-for-amazon-sp-api-smb-seller-acquisition" ,
"version" : "0.1.0" ,
"private" : true ,
"scripts" : {
"dev" : "next dev" ,
"build" : "next build" ,
"start" : "next start" ,
"lint" : "eslint ." ,
"typecheck" : "tsc --noEmit" ,
"test" : "vitest run --coverage --reporter=json --outputFile=vitest-report.json"
},
"dependencies" : {
"@assistant-ui/react" : "0.14.16" ,
"@assistant-ui/react-markdown" : "0.14.2" ,
"@aws-sdk/client-iam" : "3.1067.0" ,
"@google-cloud/vertexai" : "1.12.0" ,
"@reaatech/agent-budget-engine" : "0.1.1" ,
"@reaatech/agent-handoff" : "0.1.0" ,
"@reaatech/agent-mesh" : "1.0.0" ,
"@reaatech/llm-cost-telemetry" : "0.2.0" ,
"@reaatech/structured-repair-core" : "1.0.0" ,
"langfuse" : "3.38.20" ,
"next" : "16.2.9" ,
"react" : "19.2.4" ,
"react-dom" : "19.2.4" ,
"react-markdown" : "10.1.0" ,
"unstructured-client" : "0.31.0" ,
"zod" : "4.4.3"
},
"devDependencies" : {
"@types/node" : "20.19.43" ,
"@types/react" : "19.2.17" ,
"@types/react-dom" : "19.2.3" ,
"@vitest/coverage-v8" : "4.1.8" ,
"eslint" : "9.39.4" ,
"eslint-config-next" : "16.2.9" ,
"msw" : "2.14.6" ,
"typescript" : "5.9.3" ,
"typescript-eslint" : "8.61.0" ,
"vitest" : "4.1.8"
},
"type" : "module" ,
"packageManager" : "pnpm@10.0.0"
} Run pnpm install to lock everything down. Then make sure the scaffold works:
Expected output: No type errors. The scaffold compiles clean.
Step 2: Configure environment variables Create .env.local from the example template. Every variable the runtime reads lives here:
# Vertex AI
GOOGLE_CLOUD_PROJECT =< your-gcp-project-id >
GOOGLE_CLOUD_LOCATION = us-central1
GOOGLE_GENAI_USE_VERTEXAI = true
GOOGLE_APPLICATION_CREDENTIALS =< path-to-json >
# AWS IAM (for SP-API signing)
AWS_REGION =< your-aws-region >
AWS_ACCESS_KEY_ID =< your-aws-key >
AWS_SECRET_ACCESS_KEY =< your-aws-secret >
# SP-API LWA OAuth
LWA_CLIENT_ID =< your-lwa-client-id >
LWA_CLIENT_SECRET =< your-lwa-client-secret >
SP_API_REFRESH_TOKEN =< your-sp-api-refresh-token >
# Langfuse telemetry
LANGFUSE_PUBLIC_KEY =< your-langfuse-public-key >
LANGFUSE_SECRET_KEY =< your-langfuse-secret-key >
LANGFUSE_HOST = https://cloud.langfuse.com
# Document parsing
UNSTRUCTURED_API_KEY =< your-unstructured-key >
# Budget limits (USD)
BUDGET_PER_LEAD_USD = 0.50
BUDGET_DAILY_LIMIT_USD = 10.00
# Model
MODEL_ID = gemini-2.5-flash Set GOOGLE_APPLICATION_CREDENTIALS to the path of a downloaded GCP service-account JSON key file. For local dev, you can also use gcloud auth application-default login.
Step 3: Create the Vertex AI client Create src/lib/vertex-client.ts. This wraps the @google-cloud/vertexai SDK with methods for content generation, streaming, chat, token counting, and function calling configuration:
import { VertexAI, HarmBlockThreshold, HarmCategory, FunctionDeclarationSchemaType } from "@google-cloud/vertexai" ;
export class VertexAiClient {
private vertexAI : VertexAI ;
constructor (project : string , location : string ) {
this.vertexAI = new VertexAI ({ project, location });
}
getGenerativeModel (modelId : string ) {
return this.vertexAI. getGenerativeModel ({
model: modelId,
safetySettings: [{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE }],
generationConfig: { maxOutputTokens: 256 },
});
}
async generateContent (prompt : string , systemInstruction ?: { role : string ; parts : Array <{ text : string }> }) {
const model = this. getGenerativeModel (process.env.MODEL_ID ?? "gemini-2.5-flash" );
const request : { contents : Array <{ role : string ; parts : Array <{ text : string }> }>; systemInstruction ?: { role : string ; parts : Array <{ text : string }> } } = {
contents: [{ role: 'user' , parts: [{ text: prompt }] }],
};
if (systemInstruction) request.systemInstruction = systemInstruction;
const result = await model. generateContent (request);
return result.response;
}
async generateContentStream (prompt : string ) {
const model = this. getGenerativeModel (process.env.MODEL_ID ?? "gemini-2.5-flash" );
const streamingResult = await model. generateContentStream ({
contents: [{ role: 'user' , parts: [{ text: prompt }] }],
});
const stream = streamingResult.stream;
const response = await streamingResult.response;
return { stream, response };
}
startChat (history : Array <{ role : string ; parts : Array <{ text : string }> }>) {
const model = this. getGenerativeModel (process.env.MODEL_ID ?? "gemini-2.5-flash" );
const chat = model. startChat ({ history });
return chat;
}
async sendMessage (chat : { sendMessage : (msg : string ) => Promise <{ response : unknown }> }, message : string ) {
const result = await chat. sendMessage (message);
return result.response;
}
async sendMessageStream (chat : { sendMessageStream : (msg : string ) => Promise <{ stream : AsyncIterable < unknown >; response : unknown }> }, message : string ) {
const result = await chat. sendMessageStream (message);
return { stream: result.stream, response: await result.response };
}
async countTokens (text : string ) {
const model = this. getGenerativeModel (process.env.MODEL_ID ?? "gemini-2.5-flash" );
const response = await model. countTokens ({
contents: [{ role: 'user' , parts: [{ text }] }],
});
return response;
}
withFunctionCalling (functionDeclarations : Array <{
name : string ;
description : string ;
parameters ?: {
type : FunctionDeclarationSchemaType ;
properties : Record < string , { type : string ; description : string }>;
required ?: string [];
};
}>) {
return {
tools: [{ functionDeclarations }],
};
}
} The client reads MODEL_ID from the environment (defaulting to gemini-2.5-flash) and wraps the two key Vertex AI patterns: generateContent for one-shot LLM calls and startChat/sendMessage for multi-turn conversations.
Step 4: Implement the lead qualification agent Create src/intake/lead-qualification.ts. This agent classifies incoming seller messages, runs multi-turn qualification with retry logic, and decides whether to route the lead to SP-API registration or ask for clarification:
import { z, ZodError } from "zod" ;
import {
IncomingRequestSchema,
type IncomingRequest,
AgentResponseSchema,
type AgentResponse,
ContextPacketSchema,
type ContextPacket,
ClassifierOutputSchema,
type ClassifierOutput,
ConfidenceDecisionSchema,
type ConfidenceDecision,
} from "@reaatech/agent-mesh" ;
import { VertexAiClient } from "../lib/vertex-client.js" ;
const leadQualificationSchema = z. object ({
qualified: z. boolean (),
confidence: z. number (),
businessType: z. string (),
estimatedVolume: z.
The classify method sends the raw input to Gemini and parses the structured ClassifierOutput. The qualify method runs up to 3 attempts, re-prompting the model with specific error feedback when it returns invalid JSON or fails schema validation. The decide method routes to SP-API when confidence >= 0.8, or asks for clarification below that threshold.
Step 5: Build the SP-API client Create src/intake/spapi-client.ts. This handles SP-API communication — LWA OAuth token acquisition, AWS IAM credential validation, seller registration, document upload, and registration status polling:
import { IAMClient, GetUserCommand } from "@aws-sdk/client-iam" ;
interface SellerRegistrationPayload {
email : string ;
businessName : string ;
taxId : string ;
phone : string ;
address : Record < string , string >;
documents : DocumentUpload [];
marketplaceId : string ;
}
interface DocumentUpload {
fileName : string ;
contentType : string ;
base64Content : string
The SpApiClient.request method implements exponential backoff with jitter for 429 responses and auto-refreshes LWA tokens on 401/403. Document uploads use multipart/form-data — the correct format expected by SP-API’s document endpoint.
Step 6: Create the budget tracker Create src/lib/budget.ts. This wraps @reaatech/agent-budget-engine to enforce per-lead and daily cost caps, with soft/hard threshold warnings:
import { BudgetController } from "@reaatech/agent-budget-engine" ;
import { SpendStore } from "@reaatech/agent-budget-spend-tracker" ;
import { BudgetScope, type SpendEntry } from "@reaatech/agent-budget-types" ;
import { generateId, now } from "@reaatech/llm-cost-telemetry" ;
const BudgetScopeKey = {
Lead: BudgetScope.User,
Daily: BudgetScope.Session,
} as const ;
class MemorySpendStore extends SpendStore {
private localSpends : Map < string , number > = new Map ();
record (entry : SpendEntry ) : number {
const key = `${ entry . scopeType }:${ entry . scopeKey }` ;
this.localSpends. set (key, (this.localSpends. get (key) ?? 0 ) + entry.cost);
return 0 ;
}
getSpend (scopeType : string , scopeKey : string ) : number {
return this.localSpends. get ( `${ scopeType }:${ scopeKey }` ) ?? 0 ;
}
}
class LeadBudgetTracker {
private controller : BudgetController ;
private store : MemorySpendStore ;
constructor () {
this.store = new MemorySpendStore ();
this.controller = new BudgetController ({ spendTracker: this.store });
const perLeadLimit = parseFloat (process.env.BUDGET_PER_LEAD_USD ?? "0.50" );
const dailyLimit = parseFloat (process.env.BUDGET_DAILY_LIMIT_USD ?? "10.00" );
this.controller. defineBudget ({
scopeType: BudgetScopeKey.Lead,
scopeKey: "*" ,
limit: perLeadLimit,
policy: { softCap: 0.8 , hardCap: 1.0 },
});
this.controller. defineBudget ({
scopeType: BudgetScopeKey.Daily,
scopeKey: "*" ,
limit: dailyLimit,
policy: { softCap: 0.8 , hardCap: 1.0 },
});
this.controller. on ( "threshold-breach" , (event) => {
console. warn ( `Budget threshold breached: ${ String ( event . threshold * 100 ) }% for ${ event . scopeType }:${ event . scopeKey }` );
});
this.controller. on ( "hard-stop" , (event) => {
console. error ( `Hard stop triggered for ${ event . scopeType }:${ event . scopeKey } — spent ${ String ( event . spent ) }, limit ${ String ( event . limit ) }` );
});
}
checkLeadCost (estimatedCost : number , modelId : string ) {
try {
return this.controller. check ({
scopeType: BudgetScopeKey.Daily,
scopeKey: "*" ,
estimatedCost,
modelId,
tools: [],
});
} catch {
return { allowed: false };
}
}
recordSpend (cost : number , inputTokens : number , outputTokens : number , modelId : string , leadId : string ) {
this.controller. record ({
requestId: generateId (),
scopeType: BudgetScopeKey.Lead,
scopeKey: leadId,
cost,
inputTokens,
outputTokens,
modelId,
provider: "vertex" ,
timestamp: now (),
});
}
}
export { LeadBudgetTracker }; The tracker defines two wildcard budgets: a per-lead cap (default $0.50) and a daily cap (default $10.00). Both use a soft cap at 80% (logs a warning) and a hard cap at 100% (logs an error and blocks further requests). The MemorySpendStore keeps state in-process — replace it with a database-backed SpendStore for production.
Step 7: Add LLM telemetry Create src/lib/telemetry.ts. This integrates Langfuse for tracing LLM calls and uses @reaatech/llm-cost-telemetry for cost span validation:
import { CostSpanSchema, type CostSpan, calculateCostFromTokens } from "@reaatech/llm-cost-telemetry" ;
import Langfuse from "langfuse" ;
export class TelemetryService {
private langfuse : Langfuse ;
constructor () {
this.langfuse = new Langfuse ({
publicKey: process.env.LANGFUSE_PUBLIC_KEY ?? "" ,
secretKey: process.env.LANGFUSE_SECRET_KEY ?? "" ,
baseUrl: process.env.LANGFUSE_HOST,
});
}
createTrace (name : string , metadata ?: Record < string , unknown >) {
return this.langfuse. trace ({ name, metadata });
}
startGeneration (trace : { generation : (opts : { name : string ; model : string ; input : string }) => unknown }, opts : { name : string ; model : string ; input : string }) {
return trace. generation (opts);
}
endGeneration (gen : { end : (opts : { output : string ; usage : { inputTokens : number ; outputTokens : number } }) => void }, output : string , usage : { inputTokens : number ; outputTokens : number }) {
gen. end ({ output, usage });
}
recordCostSpan (span : CostSpan ) {
const validated = CostSpanSchema. parse (span);
const cost = calculateCostFromTokens (validated.inputTokens + validated.outputTokens, 30 );
return { validated, cost };
}
} The TelemetryService wraps Langfuse’s trace/generation lifecycle and uses CostSpanSchema for runtime validation of cost records, with calculateCostFromTokens for USD conversion.
Step 8: Build the payload repair layer Create src/repair/spapi-repair.ts. This uses @reaatech/structured-repair-core to fix malformed LLM outputs before sending them to SP-API:
import { z } from "zod" ;
import { repair, repairOutput, isValid, analyzeInput, UnrepairableError } from "@reaatech/structured-repair-core" ;
const RegistrationPayloadSchema = z. object ({
email: z. email (),
businessName: z. string (). min ( 1 ),
taxId: z. string (). min ( 1 ),
phone: z. string (),
address: z. record (z. string (), z. string ()),
marketplaceId: z. string (),
documents: z. array (z. object ({
fileName: z. string (),
contentType: z. string (),
base64Content: z. string (),
documentType: z. string (),
})),
});
class SpApiPayloadRepair {
async repairPayload (raw : string ) {
try {
const data = await repair (RegistrationPayloadSchema, raw);
return { success: true as const , data };
} catch (e) {
if (e instanceof UnrepairableError ) {
return { success: false as const , errors: [e.message], fieldErrors: [] as string [] };
}
throw e;
}
}
repairWithDiagnostics (raw : string ) {
const result = repairOutput ({
schema: RegistrationPayloadSchema,
input: raw,
debug: true ,
strategies: [ "strip-fences" , "fix-json-syntax" , "coerce-types" , "remove-extra-fields" ],
});
return result;
}
checkPayload (data : unknown ) : boolean {
return isValid (RegistrationPayloadSchema, JSON. stringify (data));
}
diagnoseInput (raw : string ) {
return analyzeInput (raw);
}
repairWithRetry (raw : string , logger : { error : (msg : string ) => void }) {
const strategies = [ "strip-fences" , "fix-json-syntax" , "coerce-types" , "remove-extra-fields" ] as const ;
for ( let attempt = 0 ; attempt < 3 ; attempt ++ ) {
const result = repairOutput ({
schema: RegistrationPayloadSchema,
input: raw,
strategies: [ ... strategies],
});
if (result.success) return result;
for ( const fe of result.fieldErrors ?? []) {
logger. error ( `Field error at ${ fe . path }: ${ fe . message }` );
}
}
return repairOutput ({
schema: RegistrationPayloadSchema,
input: raw,
strategies: [ ... strategies],
});
}
}
export { RegistrationPayloadSchema, SpApiPayloadRepair }; The SpApiPayloadRepair applies a graduated strategy pipeline: strip markdown fences, fix JSON syntax problems (trailing commas, unquoted keys), coerce string types to their schema types, and remove hallucinated extra fields. The repairWithRetry variant logs each field error and tries up to 3 times.
Step 9: Implement the agent handoff protocol Create src/intake/agent-handoff.ts. This handles the transition from conversational lead qualification to SP-API registration using @reaatech/agent-handoff:
import {
createHandoffConfig,
TypedEventEmitter,
withRetry,
pickDefined,
} from "@reaatech/agent-handoff" ;
import type {
HandoffPayload,
AgentCapabilities,
RoutingDecision,
HandoffConfig,
HandoffResult,
TransportLayer,
HandoffRouter,
DeepPartial,
} from "@reaatech/agent-handoff" ;
import {
HandoffError,
TransportError,
} from "@reaatech/agent-handoff" ;
class LeadToRegistrationHandoff implements TransportLayer {
readonly name = "lead-to-registration" ;
readonly priority
The LeadToRegistrationHandoff implements the TransportLayer interface with a typed event emitter that fires handoff_start, handoff_complete, and handoff_error events. The QualificationRouter decides whether confidence is high enough to proceed or whether the agent should ask clarifying questions.
Step 10: Wire the intake mesh orchestrator Create src/intake/intake-mesh.ts. This is the central orchestrator that ties together qualification, budget checking, circuit breaker, and handoff:
import {
CircuitBreakerStateSchema,
ContextPacketSchema,
type ContextPacket,
AgentResponseSchema,
type AgentResponse,
SessionRecordSchema,
type SessionRecord,
SessionStatus,
type CircuitBreakerState,
SERVICE_NAME,
SERVICE_VERSION,
} from "@reaatech/agent-mesh" ;
import { LeadQualificationAgent, leadQualificationSchema } from "./lead-qualification.js" ;
import { SpApiClient } from "./spapi-client.js" ;
import { LeadBudgetTracker } from "../lib/budget.js" ;
import { LeadToRegistrationHandoff } from "./agent-handoff.js" ;
class IntakeMesh {
private qualificationAgent
The processLead method runs the full pipeline: classify the input, qualify the lead with multi-turn Gemini calls, decide whether to route or clarify, check the budget, and — if everything passes — initiate the SP-API handoff. Circuit breaker transitions from OPEN to HALF_OPEN after a 30-second backoff window.
Step 11: Create the chat API route Create app/api/chat/route.ts. This is the main conversational intake endpoint:
import { NextRequest, NextResponse } from "next/server" ;
import { IncomingRequestSchema, ContextPacketSchema, AgentResponseSchema, SERVICE_NAME, SERVICE_VERSION } from "@reaatech/agent-mesh" ;
import { ZodError } from "zod" ;
import { VertexAiClient } from "../../../src/lib/vertex-client.js" ;
import { LeadQualificationAgent } from "../../../src/intake/lead-qualification.js" ;
import { SpApiClient, SpApiAuthProvider } from "../../../src/intake/spapi-client.js" ;
import { LeadBudgetTracker } from "../../../src/lib/budget.js" ;
import { LeadToRegistrationHandoff } from "../../../src/intake/agent-handoff.js" ;
import { IntakeMesh } from "../../../src/intake/intake-mesh.js" ;
let intakeMesh : IntakeMesh | null = null ;
function ensureMesh () : IntakeMesh {
if ( ! intakeMesh) {
const project = process.env.GOOGLE_CLOUD_PROJECT ?? "" ;
const location = process.env.GOOGLE_CLOUD_LOCATION ?? "us-central1" ;
const vertexClient = new VertexAiClient (project, location);
const authProvider = new SpApiAuthProvider (
process.env.LWA_CLIENT_ID ?? "" ,
process.env.LWA_CLIENT_SECRET ?? "" ,
process.env.SP_API_REFRESH_TOKEN ?? "" ,
);
const spapiClient = new SpApiClient (
authProvider,
"https://sellingpartnerapi-na.amazon.com" ,
);
const qualificationAgent = new LeadQualificationAgent (vertexClient);
const budgetTracker = new LeadBudgetTracker ();
const handoff = new LeadToRegistrationHandoff ();
intakeMesh = new IntakeMesh (qualificationAgent, spapiClient, budgetTracker, handoff);
}
return intakeMesh;
}
export async function POST (req : NextRequest ) {
try {
const body = await req. json () as Record < string , string | undefined >;
const incoming = IncomingRequestSchema. parse (body);
const context = ContextPacketSchema. parse ({
session_id: incoming.session_id ?? crypto. randomUUID (),
request_id: crypto. randomUUID (),
employee_id: incoming.employee_id ?? "anonymous" ,
display_name: incoming.display_name ?? "User" ,
raw_input: incoming.input,
intent_summary: "" ,
entities: {},
detected_language: incoming.locale ?? "en" ,
turn_history: [],
workflow_state: {},
});
const mesh = ensureMesh ();
const agentResponse = await mesh. processLead (context);
const validated = AgentResponseSchema. parse (agentResponse);
return NextResponse. json (validated, { status: 200 });
} catch (error) {
if (error instanceof ZodError ) {
return NextResponse. json (
{ error: "validation_failed" , details: error.issues },
{ status: 400 },
);
}
const message = error instanceof Error ? error.message : "unknown error" ;
if (message. toLowerCase (). includes ( "budget" )) {
return NextResponse. json (
{ error: "budget_exceeded" , message },
{ status: 429 },
);
}
throw error;
}
}
export function GET () {
return NextResponse. json ({
status: "ok" ,
service: SERVICE_NAME,
version: SERVICE_VERSION,
});
} The route uses a module-level singleton pattern for the IntakeMesh. On POST, it validates the incoming request, builds a ContextPacket, runs the mesh pipeline, and returns the validated AgentResponse. Zod validation failures return 400, budget errors return 429.
Step 12: Build the registration API route Create app/api/registration/route.ts for registration submission with payload repair and status lookup:
import { NextRequest, NextResponse } from "next/server" ;
import { SpApiClient, SpApiAuthProvider } from "../../../src/intake/spapi-client.js" ;
import { SpApiPayloadRepair, RegistrationPayloadSchema } from "../../../src/repair/spapi-repair.js" ;
import { LeadBudgetTracker } from "../../../src/lib/budget.js" ;
import { ZodError } from "zod" ;
let spapiClient : SpApiClient | null = null ;
let repair : SpApiPayloadRepair | null = null ;
let budgetTracker : LeadBudgetTracker | null = null ;
function ensureClients () {
if
The POST handler applies the structured repair pipeline to any malformed payloads before sending them to SP-API. If the payload is unrepairable, it returns 422 with detailed error info. The GET handler looks up registration status by seller ID.
Step 13: Write and run the tests The test suite covers all core modules. Here is a representative excerpt from tests/intake/lead-qualification.test.ts showing how to test the agent with mocked Vertex AI responses:
vi. hoisted (() => {
process.env.GOOGLE_CLOUD_PROJECT = "test" ;
process.env.API_KEY = "test" ;
});
import { describe, it, expect, vi, beforeEach } from "vitest" ;
import { LeadQualificationAgent } from "../../src/intake/lead-qualification.js" ;
const mockGenerateContent = vi. fn ();
vi. mock ( "../../src/lib/vertex-client.js" , () => {
class MockVertexAiClient {
generateContent = mockGenerateContent;
}
return {
VertexAiClient: MockVertexAiClient,
};
});
Now run the full test suite to verify everything works:
Expected output: vitest reports 0 failed tests. Coverage summary shows ~99% line coverage, ~99% statement coverage, ~98% function coverage, and ~91% branch coverage across all source files — well above typical quality thresholds.
Next steps
Add streaming responses — wire the Vertex AI generateContentStream through the chat route so the UI renders tokens as they arrive, reducing perceived latency.
Persist the budget store — replace MemorySpendStore with a database-backed implementation (Redis, Postgres, or Firestore) so budgets survive process restarts.
Add webhook notifications — emit events from the handoff layer to notify admin dashboards when a seller registration completes or fails.
Deploy to Cloud Run — containerize the Next.js app and deploy it to Google Cloud Run with Vertex AI and SP-API credentials injected via Secret Manager.
Add multi-language support — use the detected_language field from the classifier to route to locale-specific qualification prompts, expanding reach to non-English sellers.
string
(),
documentsCollected: z. array (z. string ()),
});
class LeadQualificationAgent {
private client : VertexAiClient ;
constructor (client : VertexAiClient ) {
this.client = client;
}
async classify (input : IncomingRequest ) : Promise < ClassifierOutput > {
IncomingRequestSchema. parse (input);
const prompt = `Classify the following seller lead input and return a JSON object with agent_id, confidence, ambiguous, detected_language, intent_summary, and entities:\n\n${ input . input }` ;
const response = await this.client. generateContent (prompt);
const text = response.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.text ?? "" ;
const result = JSON. parse (text) as Record < string , unknown >;
return ClassifierOutputSchema. parse (result);
}
async qualify (context : ContextPacket ) : Promise < AgentResponse > {
ContextPacketSchema. parse (context);
let prompt = `Qualify this seller lead. Return a JSON object with: qualified (boolean), confidence (number 0-1), businessType (string), estimatedVolume (string), documentsCollected (array of strings).\n\nSeller details: ${ context . raw_input }` ;
for ( let attempt = 0 ; attempt < 3 ; attempt ++ ) {
const response = await this.client. generateContent (prompt);
const text = response.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.text ?? "" ;
let parsed : Record < string , unknown >;
try {
parsed = JSON. parse (text) as Record < string , unknown >;
} catch {
if (attempt < 2 ) {
prompt = "Your response was not valid JSON. Please return only valid JSON matching the required schema." ;
continue ;
}
return AgentResponseSchema. parse ({
content: "Failed to qualify lead: unable to parse response as JSON." ,
workflow_complete: false ,
});
}
try {
const leadQualification = leadQualificationSchema. parse (parsed);
return AgentResponseSchema. parse ({
content: `Qualification result: ${ leadQualification . qualified ? "qualified" : "not qualified"}` ,
workflow_complete: true ,
workflow_state: { leadQualification },
});
} catch (e) {
if (e instanceof ZodError ) {
if (attempt < 2 ) {
const fieldErrors = e.issues. map (
(issue) => `${ issue . path . join ( "." ) }: ${ issue . message }` ,
). join ( "; " );
prompt = `Your previous response had validation errors: ${ fieldErrors }. Please correct these fields and return a valid JSON object matching the required schema.` ;
continue ;
}
return AgentResponseSchema. parse ({
content: "Failed to qualify lead after multiple attempts." ,
workflow_complete: false ,
});
}
throw e;
}
}
return AgentResponseSchema. parse ({
content: "Failed to qualify lead." ,
workflow_complete: false ,
});
}
decide (
classification : ClassifierOutput ,
qualificationResult : z . infer < typeof leadQualificationSchema>,
) : ConfidenceDecision {
const action = classification.confidence >= 0.8 ? "route" : "clarify" ;
return ConfidenceDecisionSchema. parse ({
action,
agent_id: classification.agent_id,
confidence: classification.confidence,
reason: qualificationResult.qualified
? "Lead meets qualification criteria"
: "Lead does not meet qualification criteria" ,
});
}
}
export { leadQualificationSchema, LeadQualificationAgent };
;
documentType : string ;
}
class SpApiError extends Error {
code : string ;
statusCode : number ;
retryable : boolean ;
constructor (code : string , message : string , statusCode : number , retryable : boolean ) {
super(message);
this.name = "SpApiError" ;
this.code = code;
this.statusCode = statusCode;
this.retryable = retryable;
}
}
class SpApiAuthProvider {
private clientId : string ;
private clientSecret : string ;
private refreshToken : string ;
private cachedToken : { accessToken : string ; expiresAt : number } | null = null ;
constructor (clientId ?: string , clientSecret ?: string , refreshToken ?: string ) {
this.clientId = clientId ?? process.env.LWA_CLIENT_ID ?? "" ;
this.clientSecret = clientSecret ?? process.env.LWA_CLIENT_SECRET ?? "" ;
this.refreshToken = refreshToken ?? process.env.SP_API_REFRESH_TOKEN ?? "" ;
}
async getLwaAccessToken () : Promise < string > {
if (this.cachedToken && Date. now () < this.cachedToken.expiresAt) {
return this.cachedToken.accessToken;
}
const response = await fetch ( "https://api.amazon.com/auth/o2/token" , {
method: "POST" ,
headers: { "Content-Type" : "application/x-www-form-urlencoded" },
body: new URLSearchParams ({
grant_type: "refresh_token" ,
client_id: this.clientId,
client_secret: this.clientSecret,
refresh_token: this.refreshToken,
}),
});
if ( ! response.ok) {
throw new SpApiError ( "lwa_auth_failed" , "LWA OAuth token request failed" , response.status, false );
}
const data = await response. json () as { access_token : string ; expires_in : number };
this.cachedToken = {
accessToken: data.access_token,
expiresAt: Date. now () + (data.expires_in - 60 ) * 1000 ,
};
return data.access_token;
}
resetCache () : void {
this.cachedToken = null ;
}
async validateAwsCredentials (region : string , accessKeyId : string , secretAccessKey : string ) {
const client = new IAMClient ({
region,
credentials: { accessKeyId, secretAccessKey },
});
await client. send ( new GetUserCommand ({}));
}
}
class SpApiClient {
private authProvider : SpApiAuthProvider ;
private baseUrl : string ;
constructor (authProvider ?: SpApiAuthProvider , baseUrl ?: string ) {
this.authProvider = authProvider ?? new SpApiAuthProvider ();
this.baseUrl = baseUrl ?? "https://sellingpartnerapi-na.amazon.com" ;
}
private async request (method : string , path : string , body ?: unknown ) : Promise < unknown > {
const maxRetries = 3 ;
let lastError : Error | null = null ;
for ( let attempt = 0 ; attempt <= maxRetries; attempt ++ ) {
try {
const token = await this.authProvider. getLwaAccessToken ();
const response = await fetch ( `${ this . baseUrl }${ path }` , {
method,
headers: {
"Content-Type" : "application/json" ,
"x-amz-access-token" : token,
},
body: body ? JSON. stringify (body) : undefined ,
});
if (response.status === 401 || response.status === 403 ) {
this.authProvider. resetCache ();
if (attempt < maxRetries) continue ;
throw new SpApiError ( "auth_failed" , `SP-API auth failed with status ${ String ( response . status ) }` , response.status, false );
}
if (response.status === 429 ) {
if (attempt < maxRetries) {
const jitter = Math. random () * 1000 ;
const delay = Math. min ( 1000 * Math. pow ( 2 , attempt) + jitter, 30000 );
await new Promise ((resolve) => setTimeout (resolve, delay));
continue ;
}
throw new SpApiError ( "rate_limited" , "SP-API rate limit exceeded after retries" , 429 , true );
}
if ( ! response.ok) {
throw new SpApiError ( "api_error" , `SP-API returned ${ String ( response . status ) }` , response.status, response.status >= 500 );
}
return ( await response. json ()) as Record < string , unknown >;
} catch (err) {
if (err instanceof SpApiError ) throw err;
lastError = err instanceof Error ? err : new Error ( String (err));
if (attempt < maxRetries) {
const delay = Math. min ( 1000 * Math. pow ( 2 , attempt) + Math. random () * 500 , 30000 );
await new Promise ((resolve) => setTimeout (resolve, delay));
}
}
}
throw lastError ?? new SpApiError ( "max_retries" , "Max retries exceeded" , 0 , true );
}
async registerSeller (data : SellerRegistrationPayload ) {
return this. request ( "POST" , "/sellers/v1/registrations" , data);
}
async uploadDocument (document : DocumentUpload ) {
const token = await this.authProvider. getLwaAccessToken ();
const formData = new FormData ();
formData. append ( "fileName" , document.fileName);
formData. append ( "contentType" , document.contentType);
formData. append ( "base64Content" , document.base64Content);
formData. append ( "documentType" , document.documentType);
const response = await fetch ( `${ this . baseUrl }/sellers/v1/documents` , {
method: "POST" ,
headers: {
"x-amz-access-token" : token,
},
body: formData,
});
if ( ! response.ok) {
throw new SpApiError (
"upload_failed" ,
`Document upload failed with status ${ String ( response . status ) }` ,
response.status,
response.status >= 500 ,
);
}
return ( await response. json ()) as Record < string , unknown >;
}
async getRegistrationStatus (sellerId : string ) {
return this. request ( "GET" , `/sellers/v1/registrations/${ sellerId }` );
}
}
export type { SellerRegistrationPayload, DocumentUpload };
export { SpApiError, SpApiAuthProvider, SpApiClient };
=
1
;
private emitter : TypedEventEmitter <{
handoff_start : HandoffPayload ;
handoff_complete : HandoffResult ;
handoff_error : HandoffError ;
}>;
constructor () {
this.emitter = new TypedEventEmitter <{
handoff_start : HandoffPayload ;
handoff_complete : HandoffResult ;
handoff_error : HandoffError ;
}>();
}
getEmitter () : TypedEventEmitter <{
handoff_start : HandoffPayload ;
handoff_complete : HandoffResult ;
handoff_error : HandoffError ;
}> {
return this.emitter;
}
async sendHandoff (request : {
payload : HandoffPayload ;
targetAgent : AgentCapabilities ;
sourceAgent ?: AgentCapabilities ;
timeout ?: number ;
requireExplicitAcceptance ?: boolean ;
}) : Promise <{
accepted : boolean ;
responseCode : number ;
message ?: string ;
receivingAgent ?: AgentCapabilities ;
timestamp : Date ;
customData ?: Record < string , unknown >;
}> {
this.emitter. emit ( "handoff_start" , request.payload);
try {
const payload = pickDefined ({
handoffId: request.payload.handoffId,
sessionId: request.payload.sessionId,
sourceAgent: request.sourceAgent?.agentId,
targetAgent: request.targetAgent.agentId,
});
const result = await withRetry (
() => {
const response = {
accepted: true ,
responseCode: 200 ,
message: "Registration handoff initiated" ,
receivingAgent: request.targetAgent,
timestamp: new Date (),
customData: payload,
};
return Promise . resolve (response);
},
{
maxRetries: 3 ,
backoff: "exponential" as const ,
baseDelayMs: 100 ,
maxDelayMs: 5000 ,
shouldRetry : (err : unknown ) => err instanceof Error ,
},
);
this.emitter. emit ( "handoff_complete" , {
success: true ,
handoffId: request.payload.handoffId,
receivingAgent: result.receivingAgent,
routingDecision: { type: "primary" , targetAgent: request.targetAgent, confidence: 1 , alternatives: [] },
timestamp: result.timestamp,
});
return result;
} catch (error) {
const handoffError =
error instanceof HandoffError
? error
: new TransportError ( "Handoff transport failed" );
this.emitter. emit ( "handoff_error" , handoffError);
throw handoffError;
}
}
validateConnection (agent : AgentCapabilities ) : Promise < boolean > {
return Promise . resolve (agent.availability === "available" );
}
getCapabilities () : {
supportsStreaming : boolean ;
supportsCompression : boolean ;
maxPayloadSizeBytes : number ;
protocols : string [];
} {
return {
supportsStreaming: false ,
supportsCompression: true ,
maxPayloadSizeBytes: 1048576 ,
protocols: [ "a2a" ],
};
}
}
class QualificationRouter implements HandoffRouter {
private threshold : number ;
constructor (threshold : number = 0.7 ) {
this.threshold = threshold;
}
route (
payload : HandoffPayload ,
availableAgents : AgentCapabilities [],
) : Promise < RoutingDecision > {
const confidence = (payload.conversationState.contextVariables.confidence ?? 0 ) as number ;
if (confidence >= this.threshold && availableAgents.length > 0 ) {
return Promise . resolve ({
type: "primary" ,
targetAgent: availableAgents[ 0 ],
confidence,
alternatives: availableAgents. slice ( 1 ),
});
}
return Promise . resolve ({
type: "clarification" ,
candidateAgents: availableAgents,
clarificationQuestions: [
"Can you provide more details about your business?" ,
],
recommendedAction: "ask_user" ,
});
}
}
function createHandoffConfiguration (
overrides ?: DeepPartial < HandoffConfig >,
) : HandoffConfig {
return createHandoffConfig (
overrides ?? { routing: { minConfidenceThreshold: 0.7 } },
);
}
export {
LeadToRegistrationHandoff,
QualificationRouter,
createHandoffConfiguration,
};
:
LeadQualificationAgent
;
private spApiClient : SpApiClient ;
private budgetTracker : LeadBudgetTracker ;
private handoff : LeadToRegistrationHandoff ;
private sessions : Map < string , SessionRecord >;
private startTime : number ;
private circuitBreakerStates : Map < string , CircuitBreakerState >;
constructor (
qualificationAgent : LeadQualificationAgent ,
spApiClient : SpApiClient ,
budgetTracker : LeadBudgetTracker ,
handoff : LeadToRegistrationHandoff ,
) {
this.qualificationAgent = qualificationAgent;
this.spApiClient = spApiClient;
this.budgetTracker = budgetTracker;
this.handoff = handoff;
this.sessions = new Map < string , SessionRecord >();
this.startTime = Date. now ();
this.circuitBreakerStates = new Map < string , CircuitBreakerState >();
}
setCircuitBreakerState (agentId : string , state : CircuitBreakerState ) : void {
this.circuitBreakerStates. set (agentId, state);
}
async processLead (context : ContextPacket ) : Promise < AgentResponse > {
const validated = ContextPacketSchema. parse (context);
const sessionId = validated.session_id;
const userId = validated.employee_id;
let session : SessionRecord ;
const existing = this.sessions. get (sessionId);
if (existing) {
session = existing;
} else {
session = SessionRecordSchema. parse ({
session_id: sessionId,
user_id: userId,
employee_id: validated.employee_id,
status: "active" as SessionStatus ,
active_agent: SERVICE_NAME,
turn_history: [],
workflow_state: {},
created_at: new Date (). toISOString (),
updated_at: new Date (). toISOString (),
ttl: new Date (Date. now () + 3600000 ),
});
this.sessions. set (sessionId, session);
}
const CIRCUIT_BREAKER_BACKOFF_MS = 30000 ;
for ( const [agentId, state] of this.circuitBreakerStates) {
if (state.state === "OPEN" ) {
const elapsed = Date. now () - (state.last_failure_time ?? 0 );
if (elapsed >= CIRCUIT_BREAKER_BACKOFF_MS) {
this.circuitBreakerStates. set (agentId, CircuitBreakerStateSchema. parse ({
... state,
state: "HALF_OPEN" as const ,
last_state_change: Date. now (),
}));
} else {
return AgentResponseSchema. parse ({
content: "Service temporarily unavailable. Please try again later." ,
workflow_complete: false ,
workflow_state: { sessionId, reason: "circuit_breaker_open" },
});
}
}
}
try {
const classification = await this.qualificationAgent. classify ({
input: validated.raw_input,
session_id: sessionId,
employee_id: validated.employee_id,
display_name: validated.display_name,
});
this.circuitBreakerStates. set ( "qualification-agent" , CircuitBreakerStateSchema. parse ({
agent_id: "qualification-agent" ,
state: "CLOSED" ,
failure_count: 0 ,
success_count: 1 ,
last_state_change: Date. now (),
}));
const qualifyResponse = await this.qualificationAgent. qualify (validated);
const rawQualification = qualifyResponse.workflow_state?.leadQualification;
const leadQualification = leadQualificationSchema. parse (
rawQualification ?? {},
);
const decision = this.qualificationAgent. decide (
classification,
leadQualification,
);
const budgetCheck = this.budgetTracker. checkLeadCost (
0.01 ,
"gemini-2.5-flash" ,
);
if (decision.action === "route" && budgetCheck.allowed) {
const handoffResult = await this.handoff. sendHandoff ({
payload: {
handoffId: `handoff_${ sessionId }` ,
sessionId,
conversationId: `conv_${ sessionId }` ,
sessionHistory: [],
compressedContext: {
summary: validated.intent_summary,
keyFacts: [],
intents: [{ intent: classification.intent_summary, confidence: classification.confidence, entities: [] }],
entities: [],
openItems: [],
compressionMethod: "summary" ,
originalTokenCount: 0 ,
compressedTokenCount: 0 ,
compressionRatio: 0 ,
},
handoffReason: {
type: "specialist_required" as const ,
requiredSkills: [ "sp-api-registration" ],
currentAgentSkills: [ "lead-qualification" ],
},
userMetadata: {
userId: validated.employee_id,
},
conversationState: {
resolvedEntities: validated.entities,
openQuestions: [],
contextVariables: { confidence: classification.confidence },
},
createdAt: new Date (),
},
targetAgent: {
agentId: "sp-api-registration" ,
agentName: "SP-API Registration Agent" ,
skills: [ "sp-api-registration" , "seller-registration" ],
domains: [ "amazon-sp-api" ],
maxConcurrentSessions: 10 ,
currentLoad: 0 ,
languages: [ "en" ],
specializations: [{ domain: "amazon-sp-api" , proficiencyLevel: 1 , minConfidenceThreshold: 0.7 }],
availability: "available" ,
version: "1.0.0" ,
},
});
this.budgetTracker. recordSpend (
0.01 ,
100 ,
50 ,
"gemini-2.5-flash" ,
sessionId,
);
this.sessions. set (sessionId, {
... session,
status: "completed" ,
updated_at: new Date (). toISOString (),
workflow_state: { handoffId: handoffResult.message },
});
return AgentResponseSchema. parse ({
content: `Registration handoff initiated for seller.` ,
workflow_complete: true ,
workflow_state: {
sessionId,
decision: decision.action,
handoffInitiated: true ,
},
});
}
if ( ! budgetCheck.allowed) {
return AgentResponseSchema. parse ({
content: `Budget limit reached. Cannot process lead at this time.` ,
workflow_complete: false ,
workflow_state: { sessionId, reason: "budget_exceeded" },
});
}
return AgentResponseSchema. parse ({
content: `Clarification needed: ${ decision . reason }` ,
workflow_complete: false ,
workflow_state: { sessionId, decision: decision.action },
});
} catch (error) {
this.sessions. set (sessionId, {
... session,
status: "error" ,
updated_at: new Date (). toISOString (),
});
this.circuitBreakerStates. set ( "qualification-agent" , CircuitBreakerStateSchema. parse ({
agent_id: "qualification-agent" ,
state: "OPEN" ,
failure_count: 1 ,
success_count: 0 ,
last_failure_time: Date. now (),
last_state_change: Date. now (),
}));
throw error;
}
}
getHealth () : {
status : "ok" | "unhealthy" | "degraded" ;
version : string ;
uptime_ms : number ;
checks : Record < string , { status : "pass" | "fail" | "warn" ; message ?: string ; latency_ms ?: number }>;
} {
return {
status: "ok" ,
version: SERVICE_VERSION,
uptime_ms: Date. now () - this.startTime,
checks: {
session_store: {
status: "pass" ,
message: `Active sessions: ${ String ( this . sessions . size ) }` ,
},
},
};
}
}
export { IntakeMesh };
(
!
spapiClient) {
const authProvider = new SpApiAuthProvider (
process.env.LWA_CLIENT_ID ?? "" ,
process.env.LWA_CLIENT_SECRET ?? "" ,
process.env.SP_API_REFRESH_TOKEN ?? "" ,
);
spapiClient = new SpApiClient (authProvider, "https://sellingpartnerapi-na.amazon.com" );
}
if ( ! repair) repair = new SpApiPayloadRepair ();
if ( ! budgetTracker) budgetTracker = new LeadBudgetTracker ();
return { spapiClient, repair, budgetTracker };
}
export async function POST (req : NextRequest ) {
try {
const body = await req. json () as Record < string , unknown >;
const raw = typeof body === "string" ? body : JSON. stringify (body);
const { spapiClient, repair, budgetTracker } = ensureClients ();
const repaired = await repair. repairPayload (raw);
if ( ! repaired.success) {
return NextResponse. json (
{
error: "unrepairable_payload" ,
errors: repaired.errors,
},
{ status: 422 },
);
}
const validated = RegistrationPayloadSchema. parse (repaired.data);
const result = await spapiClient. registerSeller (validated) as { registrationId ?: string };
budgetTracker. recordSpend ( 0.01 , 0 , 0 , "gemini-2.5-flash" , result.registrationId ?? "unknown" );
return NextResponse. json (
{ registrationId: result.registrationId ?? "unknown" , status: "registered" },
{ status: 200 },
);
} catch (error) {
if (error instanceof ZodError ) {
return NextResponse. json (
{ error: "validation_failed" , details: error.issues },
{ status: 422 },
);
}
const message = error instanceof Error ? error.message : "unknown error" ;
if (message. includes ( "auth" ) || message. includes ( "401" ) || message. includes ( "403" )) {
return NextResponse. json (
{ error: "spapi_auth_failed" , message },
{ status: 502 },
);
}
throw error;
}
}
export async function GET (
_req : NextRequest ,
{ params } : { params : Promise <{ id : string }> },
) {
const { id } = await params;
try {
const status = await ensureClients ().spapiClient. getRegistrationStatus (id);
if ( ! status) {
return NextResponse. json (
{ error: "registration_not_found" },
{ status: 404 },
);
}
return NextResponse. json (status, { status: 200 });
} catch (error) {
const message = error instanceof Error ? error.message : "unknown error" ;
if (message. includes ( "404" ) || message. includes ( "not found" )) {
return NextResponse. json (
{ error: "registration_not_found" },
{ status: 404 },
);
}
throw error;
}
}
describe ( "LeadQualificationAgent" , () => {
let agent : LeadQualificationAgent ;
beforeEach ( async () => {
vi. clearAllMocks ();
const { VertexAiClient } = await import ( "../../src/lib/vertex-client.js" );
agent = new LeadQualificationAgent ( new VertexAiClient ( "p" , "l" ));
});
describe ( "classify" , () => {
it ( "returns parsed ClassifierOutput from valid LLM response" , async () => {
mockGenerateContent. mockResolvedValueOnce ({
candidates: [{
content: { parts: [{ text: JSON. stringify ({
agent_id: "lead-qualifier" ,
confidence: 0.95 ,
ambiguous: false ,
detected_language: "en" ,
intent_summary: "seller registration interest" ,
entities: { product_category: "electronics" },
}) }] },
}],
});
const result = await agent. classify ({
input: "I want to sell electronics on Amazon" ,
session_id: "550e8400-e29b-41d4-a716-446655440000" ,
employee_id: "emp-1" ,
display_name: "Test User" ,
});
expect (result.agent_id). toBe ( "lead-qualifier" );
expect (result.confidence). toBe ( 0.95 );
});
});
describe ( "qualify" , () => {
it ( "returns AgentResponse with qualification data on success" , async () => {
mockGenerateContent. mockResolvedValueOnce ({
candidates: [{
content: { parts: [{ text: JSON. stringify ({
qualified: true ,
confidence: 0.85 ,
businessType: "retail" ,
estimatedVolume: "100k-500k" ,
documentsCollected: [ "tax_id" , "business_license" ],
}) }] },
}],
});
const result = await agent. qualify ({
session_id: "550e8400-e29b-41d4-a716-446655440000" ,
request_id: "6ba7b810-9dad-11d1-80b4-00c04fd430c8" ,
employee_id: "emp-1" ,
display_name: "Test User" ,
raw_input: "I want to sell electronics on Amazon" ,
intent_summary: "seller registration interest" ,
entities: { product_category: "electronics" },
detected_language: "en" ,
turn_history: [],
workflow_state: {},
});
expect (result.workflow_complete). toBe ( true );
expect (result.content). toContain ( "qualified" );
});
});
describe ( "decide" , () => {
it ( "returns route action when confidence >= 0.8" , () => {
const result = agent. decide (
{ agent_id: "lead-qualifier" , confidence: 0.9 , ambiguous: false , detected_language: "en" , intent_summary: "seller registration" , entities: {} },
{ qualified: true , confidence: 0.85 , businessType: "retail" , estimatedVolume: "100k" , documentsCollected: [ "tax_id" ] },
);
expect (result.action). toBe ( "route" );
});
it ( "returns clarify action when confidence < 0.8" , () => {
const result = agent. decide (
{ agent_id: "lead-qualifier" , confidence: 0.5 , ambiguous: true , detected_language: "en" , intent_summary: "unknown" , entities: {} },
{ qualified: false , confidence: 0.3 , businessType: "" , estimatedVolume: "" , documentsCollected: [] },
);
expect (result.action). toBe ( "clarify" );
});
});
});