diff --git a/.env.example b/.env.example index 3be3f9fb2..67eaf06b0 100644 --- a/.env.example +++ b/.env.example @@ -120,6 +120,13 @@ GOOGLE_CLIENT_SECRET="" # already knows (your own email and calendar history) and simply reports what # it could not check. Each key you add unlocks one more place it can look, and # it tells you at startup which ones are on. +# +# Full agentic mode checklist (env names only; Context is Settings → General): +# RAPIDAPI_KEY +# PERPLEXITY_API_KEY +# BLOB_READ_WRITE_TOKEN +# AGENT_BRIDGE_SECRET (same value for app + agent; see block above) +# Context key (Settings → General — not a variable) # Perplexity — finds where a person lives on the web, and recent news worth # knowing before a call. https://perplexity.ai/settings/api diff --git a/apps/agent/agent/lib/agent-manifest.ts b/apps/agent/agent/lib/agent-manifest.ts index f8d8b661c..9bd803af4 100644 --- a/apps/agent/agent/lib/agent-manifest.ts +++ b/apps/agent/agent/lib/agent-manifest.ts @@ -1,6 +1,7 @@ import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; import { z } from "zod"; import { AGENT_ACTION_TYPES } from "./agent-actions"; +import { LIFECYCLE_ROLES } from "./lifecycle-roles"; const slackDestination = z.object({ kind: z.enum(["channel", "user"]), @@ -65,6 +66,7 @@ export const agentManifestResource = z.object({ export const agentManifest = z .object({ description: z.string().optional(), + lifecycleRole: z.enum(LIFECYCLE_ROLES).optional(), actions: z.array(agentManifestAction).min(1), triggers: z.array(agentManifestTrigger).min(1), dataScope: z.object({ diff --git a/apps/agent/agent/lib/approval.ts b/apps/agent/agent/lib/approval.ts index 04f9707c1..2b05b55f9 100644 --- a/apps/agent/agent/lib/approval.ts +++ b/apps/agent/agent/lib/approval.ts @@ -27,3 +27,20 @@ export function sensitiveWrite(instead: string): Approval { } : "user-approval"; } + +export function sensitiveWhen( + needsApproval: (input: TInput | undefined) => boolean, + instead: string, +): Approval { + return ({ session, toolInput }) => { + if (!needsApproval(toolInput as TInput | undefined)) { + return "not-applicable"; + } + return isAutomated(session) + ? { + type: "denied" as const, + reason: `Not something to do unattended. ${instead}`, + } + : "user-approval"; + }; +} diff --git a/apps/agent/agent/lib/brand.ts b/apps/agent/agent/lib/brand.ts index d9b30a3a7..dafc4a3db 100644 --- a/apps/agent/agent/lib/brand.ts +++ b/apps/agent/agent/lib/brand.ts @@ -58,10 +58,10 @@ export async function runBrand({ if (!company) return { enriched: false, reason: "No such company." }; if (!(await contextDevEnabled())) { - const reason = - "Context.dev is not configured, so there is nowhere to look."; - await settle(companyId, EnrichmentStatus.SKIPPED, reason); - return { enriched: false, reason }; + return { + enriched: false, + reason: "Context.dev is not configured, so there is nowhere to look.", + }; } if (!company.domain) { diff --git a/apps/agent/agent/lib/brief-identity.ts b/apps/agent/agent/lib/brief-identity.ts new file mode 100644 index 000000000..5f7f2fa83 --- /dev/null +++ b/apps/agent/agent/lib/brief-identity.ts @@ -0,0 +1,60 @@ +import { FactBand } from "@crm/db"; +import { type Evidence, type EvidenceKind, scoreEvidence } from "./evidence"; +import { isDerivedName } from "./names"; + +export const IDENTITY_PROOF_KINDS = [ + "profile.email-match", + "linkedin.employer-and-name", + "crm.thread-reply", + "crm.signature-block", + "github.account-identity", +] as const satisfies readonly EvidenceKind[]; + +const identityKinds = new Set(IDENTITY_PROOF_KINDS); + +export type ContactIdentitySnapshot = { + email: string | null; + firstName: string; + lastName: string | null; + linkedinUrl: string | null; + hasAppliedName: boolean; +}; + +export function evidenceProvesIdentity(evidence: Evidence[]): boolean { + return evidence.some((item) => identityKinds.has(item.kind)); +} + +export function contactIdentityIsTrustworthy( + contact: ContactIdentitySnapshot, +): boolean { + if (contact.linkedinUrl) return true; + if (contact.hasAppliedName) return true; + if ( + contact.lastName && + !isDerivedName(contact.email, contact.firstName, contact.lastName) + ) { + return true; + } + return false; +} + +export function refuseBriefReason(input: { + contact: ContactIdentitySnapshot; + evidence: Evidence[]; +}): string | null { + const identityOk = + contactIdentityIsTrustworthy(input.contact) || + evidenceProvesIdentity(input.evidence); + + if (!identityOk) { + return "Identity is not trustworthy yet. Identify them first, then write the brief."; + } + + const scored = scoreEvidence(input.evidence); + + if (scored.band === null || scored.band === FactBand.POSSIBLE) { + return "Nothing here is sourced well enough to put on the record."; + } + + return null; +} diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts index 8a20494c0..efedac6f6 100644 --- a/apps/agent/agent/lib/builder-runtime.ts +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -8,6 +8,7 @@ import { import { readAgentModel } from "@crm/db/settings"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { AGENT_ACTION_TYPES, actionDependency } from "./agent-actions"; +import type { LifecycleRole } from "./lifecycle-roles"; import { requestStaleSlackInventorySync } from "./slack-people"; const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; @@ -56,6 +57,7 @@ export type DraftAgentInput = { name: string; description: string; instructions: string; + lifecycleRole?: LifecycleRole; triggers: DraftTrigger[]; recordScope: "SELECTED" | "WORKSPACE"; resources: BuilderResource[]; @@ -232,6 +234,7 @@ export async function saveBuilderDraft( kind: "crm-team-agent", name: input.name, description: input.description, + ...(input.lifecycleRole ? { lifecycleRole: input.lifecycleRole } : {}), triggers: manifestTriggers, dataScope: { mode: input.recordScope, diff --git a/apps/agent/agent/lib/capabilities.ts b/apps/agent/agent/lib/capabilities.ts index cef15dcc0..77c8480b8 100644 --- a/apps/agent/agent/lib/capabilities.ts +++ b/apps/agent/agent/lib/capabilities.ts @@ -13,6 +13,50 @@ export type Capability = { readonly from: string; }; +export type EnableChecklistItem = { + readonly id: string; + readonly label: string; + readonly source: string; + readonly kind: "env" | "setting"; +}; + +export const FULL_AGENTIC_CHECKLIST: readonly EnableChecklistItem[] = [ + { + id: "RAPIDAPI_KEY", + label: "LinkedIn", + source: "RAPIDAPI_KEY", + kind: "env", + }, + { + id: "PERPLEXITY_API_KEY", + label: "Web research", + source: "PERPLEXITY_API_KEY", + kind: "env", + }, + { + id: CONTEXT_DEV, + label: "Company brand data", + source: "Settings → General", + kind: "setting", + }, + { + id: "BLOB_READ_WRITE_TOKEN", + label: "Picture storage", + source: "BLOB_READ_WRITE_TOKEN", + kind: "env", + }, + { + id: "AGENT_BRIDGE_SECRET", + label: "Agent panel", + source: "AGENT_BRIDGE_SECRET", + kind: "env", + }, +] as const; + +export const FULL_AGENTIC_ENV_VARS = FULL_AGENTIC_CHECKLIST.filter( + (item) => item.kind === "env", +).map((item) => item.source); + export async function contextDevKey(): Promise { try { return await readContextDevKey(db); @@ -51,13 +95,14 @@ export function capabilitiesFrom( ...fromEnv("PERPLEXITY_API_KEY"), label: "Web research", gives: - "open-web context with citations, and the search that finds a LinkedIn slug in the first place", + "open-web context with citations for research, not for identity matching", }, { id: CONTEXT_DEV, from: "Settings → General", label: "Company brand data", - gives: "a company's logo, industry, location and socials from its domain", + gives: + "a company's logo, industry, location and socials from its domain, and Context web search that finds LinkedIn candidate slugs for identity matching", enabled: contextDev !== null, }, { @@ -66,6 +111,12 @@ export function capabilitiesFrom( gives: "somewhere to keep a logo or a profile photo. Without it a record has no picture at all, because the URLs these sources hand back expire and are never stored as they are", }, + { + ...fromEnv("AGENT_BRIDGE_SECRET"), + label: "Agent panel", + gives: + "a rep can open a contact, company or deal Agent tab and talk to you live, and the API can poke dispatch without waiting for the schedule", + }, ]; } @@ -137,3 +188,29 @@ export function markdownFor(all: readonly Capability[]): string { return lines.join("\n"); } + +export function enableChecklistMarkdown( + all: readonly Capability[] = capabilitiesFrom(null), +): string { + const byId = new Map(all.map((capability) => [capability.id, capability])); + const lines = [ + "## Full agentic mode enable checklist", + "", + "Env vars only (names, never values). Same value for `AGENT_BRIDGE_SECRET` on the app and the agent.", + "", + ]; + + for (const item of FULL_AGENTIC_CHECKLIST) { + if (item.kind === "env") { + const on = byId.get(item.id)?.enabled === true; + lines.push(`- [${on ? "x" : " "}] \`${item.source}\` — ${item.label}`); + } else { + const on = byId.get(item.id)?.enabled === true; + lines.push( + `- [${on ? "x" : " "}] Context key at \`${item.source}\` — ${item.label} (not an env var)`, + ); + } + } + + return lines.join("\n"); +} diff --git a/apps/agent/agent/lib/crm.ts b/apps/agent/agent/lib/crm.ts index 91599187f..aefa44857 100644 --- a/apps/agent/agent/lib/crm.ts +++ b/apps/agent/agent/lib/crm.ts @@ -381,3 +381,34 @@ export async function writeTimelineNote( return activity.id; } + +export async function writeOwnerTask( + contactId: string, + subject: string, + body: string, + dueAt: Date, + meta: Record = {}, +): Promise { + const contact = await db.contact.findUnique({ + where: { id: contactId }, + select: { companyId: true, ownerId: true }, + }); + if (!contact?.ownerId) return null; + + const activity = await db.activity.create({ + data: { + type: "TASK", + subject, + body, + occurredAt: new Date(), + dueAt, + contactId, + companyId: contact.companyId, + createdById: contact.ownerId, + meta: { ...meta, agent: "people-research" }, + }, + select: { id: true }, + }); + + return activity.id; +} diff --git a/apps/agent/agent/lib/deal-intelligence.ts b/apps/agent/agent/lib/deal-intelligence.ts new file mode 100644 index 000000000..af2d89f80 --- /dev/null +++ b/apps/agent/agent/lib/deal-intelligence.ts @@ -0,0 +1,90 @@ +import { db } from "@crm/db"; +import { + blankToNull, + clampDealScore, + DEAL_SCORE, + isValidDealScore, +} from "@crm/db/deal-score"; + +export type WriteDealIntelligenceInput = { + dealId: string; + score: number; + summary: string; + forecastContext: string; +}; + +export type WriteDealIntelligenceResult = + | { + written: true; + score: number; + scoredAt: string; + } + | { + written: false; + reason: string; + }; + +export async function writeDealIntelligence( + input: WriteDealIntelligenceInput, +): Promise { + const score = clampDealScore(input.score); + if (!isValidDealScore(score)) { + return { + written: false, + reason: `Score must be an integer from ${DEAL_SCORE.min} to ${DEAL_SCORE.max}.`, + }; + } + + const summary = blankToNull(input.summary); + if (!summary) { + return { + written: false, + reason: "Score summary is required.", + }; + } + if (summary.length > DEAL_SCORE.summaryMax) { + return { + written: false, + reason: `Score summary must be at most ${DEAL_SCORE.summaryMax} characters.`, + }; + } + + const forecastContext = blankToNull(input.forecastContext); + if (!forecastContext) { + return { + written: false, + reason: "Forecast context is required.", + }; + } + if (forecastContext.length > DEAL_SCORE.forecastMax) { + return { + written: false, + reason: `Forecast context must be at most ${DEAL_SCORE.forecastMax} characters.`, + }; + } + + const deal = await db.deal.findUnique({ + where: { id: input.dealId }, + select: { id: true }, + }); + if (!deal) { + return { written: false, reason: "No such deal." }; + } + + const scoredAt = new Date(); + await db.deal.update({ + where: { id: input.dealId }, + data: { + dealScore: score, + dealScoreSummary: summary, + dealScoredAt: scoredAt, + forecastContext, + }, + }); + + return { + written: true, + score, + scoredAt: scoredAt.toISOString(), + }; +} diff --git a/apps/agent/agent/lib/dispatch.ts b/apps/agent/agent/lib/dispatch.ts index 5f0b9a048..dfb63359f 100644 --- a/apps/agent/agent/lib/dispatch.ts +++ b/apps/agent/agent/lib/dispatch.ts @@ -9,6 +9,7 @@ import { collapsing, runLimited } from "./pool"; import { runPortrait } from "./portrait"; import { runSlackChannelJoin } from "./slack-join-task"; import { runSlackPeopleMatch } from "./slack-people"; +import { flagStalledDeal } from "./stalled-deal"; import { claimDue, completeTask, @@ -146,6 +147,11 @@ async function handleDirect(task: LeasedTask): Promise { return; } + if (task.kind === "stalled-deal") { + await completeTask(task.id, await flagStalledDeal(task)); + return; + } + await completeTask(task.id, "The record this names is gone."); } @@ -413,13 +419,15 @@ function work(kind: string, reason: string): string { return "Work out who this contact actually is, and record what you find. Read what we already have before spending anything."; case "profile": case "recheck": - return "Bring this contact's record up to date: their background, their current role, and anything that has changed since we last looked."; + return "Bring this contact's record up to date: their background, their current role, and anything that has changed since we last looked. If their employer has moved, load job-change, record the new employer, then call record_job_change without moveToCompanyId."; case "meeting-prep": - return "There is a meeting with this person soon. Make sure whoever is taking it opens the record knowing who they are dealing with."; + return "There is a meeting with this person soon. Load meeting-prep and identity-matching. If identity is not trustworthy, identify them first. Only then write a brief with write_brief — the tool refuses garbage identity. Leave the panel empty rather than invent."; case "company-profile": return "This company's brand, industry, location and links are filled in separately and may already be there. Read the account, fill anything still missing, and write a brief if there is something worth saying."; case "workspace-profile": return "Write the profile of the company you work for, so that every other session knows who we are. Read our own site and keep it short."; + case "deal-score": + return "Score this deal from 0–100 and write the forecast context. Read the deal history first. Base the score on stage age, activity recency and cadence, contact coverage (champion and economic buyer), and what the notes say. Call write_deal_intelligence once with the score, a one-paragraph rationale, and a rolling timeline summary. Do not send outreach. Do not change stage or ownership. Do not overwrite forecastContextManual."; default: return `Handle this: ${reason}`; } diff --git a/apps/agent/agent/lib/facts.ts b/apps/agent/agent/lib/facts.ts index ebcc02e16..402c4da29 100644 --- a/apps/agent/agent/lib/facts.ts +++ b/apps/agent/agent/lib/facts.ts @@ -1,4 +1,5 @@ import { db, FactBand, FactStatus } from "@crm/db"; +import { refuseBriefReason } from "./brief-identity"; import { type Evidence, scoreEvidence } from "./evidence"; import { currentFocus } from "./focus"; import { isDerivedName, splitName } from "./names"; @@ -277,11 +278,45 @@ export async function writeBrief(input: { }): Promise<{ written: boolean; score: number; reason?: string }> { const scored = scoreEvidence(input.evidence); - if (scored.band === null) { + const contact = await db.contact.findUnique({ + where: { id: input.contactId }, + select: { + email: true, + firstName: true, + lastName: true, + linkedinUrl: true, + facts: { + where: { field: "name", status: FactStatus.APPLIED }, + select: { id: true }, + take: 1, + }, + }, + }); + + if (!contact) { + return { + written: false, + score: scored.score, + reason: "No such contact.", + }; + } + + const refused = refuseBriefReason({ + contact: { + email: contact.email, + firstName: contact.firstName, + lastName: contact.lastName, + linkedinUrl: contact.linkedinUrl, + hasAppliedName: contact.facts.length > 0, + }, + evidence: input.evidence, + }); + + if (refused) { return { written: false, score: scored.score, - reason: "Nothing here is sourced well enough to put on the record.", + reason: refused, }; } diff --git a/apps/agent/agent/lib/identity-verdict.ts b/apps/agent/agent/lib/identity-verdict.ts new file mode 100644 index 000000000..2e51337fd --- /dev/null +++ b/apps/agent/agent/lib/identity-verdict.ts @@ -0,0 +1,76 @@ +import type { Evidence } from "./evidence"; +import type { Profile } from "./linkdapi"; +import { looksLikeSameCompany, nameMatchesLocalPart } from "./names"; + +export type IdentityChecks = { + employerMatches: boolean; + nameMatches: boolean; + isSamePerson: boolean; +}; + +export function identityChecks( + profile: Pick, + email: string, + companyName: string, + companyDomain: string, +): IdentityChecks { + const local = email.split("@")[0] ?? ""; + const employerMatches = profile.positions.some((position) => + looksLikeSameCompany(position.name, companyName, companyDomain), + ); + const nameMatches = nameMatchesLocalPart(profile, local); + + return { + employerMatches, + nameMatches, + isSamePerson: employerMatches && nameMatches, + }; +} + +export function identityEvidence( + profile: Pick< + Profile, + "firstName" | "lastName" | "fullName" | "profileUrl" | "positions" + >, + checks: IdentityChecks, + email: string, + companyName: string, +): Evidence[] { + const joined = [profile.firstName, profile.lastName] + .filter(Boolean) + .join(" "); + const name = profile.fullName ?? (joined || "unnamed profile"); + const employers = profile.positions.map((p) => p.name).filter(Boolean); + const employerList = + employers.length > 0 ? employers.join(", ") : "no current employer listed"; + + if (checks.isSamePerson) { + return [ + { + kind: "linkedin.employer-and-name", + detail: `${name} at ${employerList}; name is consistent with ${email}`, + sourceUrl: profile.profileUrl, + }, + ]; + } + + if (checks.employerMatches) { + return [ + { + kind: "employer-only", + detail: `${name} lists ${companyName}, but the name is not consistent with ${email}`, + sourceUrl: profile.profileUrl, + }, + ]; + } + + return []; +} + +export function identityNextStep(checks: IdentityChecks): string { + if (checks.isSamePerson) { + return "Same person. Call identify_contact with the evidence array from this result."; + } + + return "Not them. Stop. A miss stays a miss — do not call identify_contact for this slug."; +} diff --git a/apps/agent/agent/lib/job-change.ts b/apps/agent/agent/lib/job-change.ts new file mode 100644 index 000000000..fc8239c4d --- /dev/null +++ b/apps/agent/agent/lib/job-change.ts @@ -0,0 +1,98 @@ +import { db } from "@crm/db"; +import { writeOwnerTask, writeTimelineNote } from "./crm"; +import { lastEmployerChange } from "./facts"; +import { daysFromNow, JOB_CHANGE } from "./recheck-config"; + +export type RaiseJobChangeInput = { + contactId: string; + moveToCompanyId?: string; +}; + +export type RaiseJobChangeResult = + | { + raised: false; + reason: string; + } + | { + raised: true; + from: string; + to: string; + moved: boolean; + ownerNotified: boolean; + noteId: string | null; + taskId: string | null; + }; + +export async function raiseJobChange( + input: RaiseJobChangeInput, +): Promise { + const { contactId, moveToCompanyId } = input; + + const change = await lastEmployerChange(contactId); + if (!change) { + return { + raised: false, + reason: "No employer change on the facts for this contact.", + }; + } + + const contact = await db.contact.findUnique({ + where: { id: contactId }, + select: { + firstName: true, + lastName: true, + ownerId: true, + companyId: true, + }, + }); + if (!contact) return { raised: false, reason: "No such contact." }; + + const name = [contact.firstName, contact.lastName].filter(Boolean).join(" "); + + const subject = `${name} has moved to ${change.to}`; + const body = [ + `${name} appears to have left ${change.from} for ${change.to}.`, + change.sourceUrl ?? "", + "", + "Worth a conversation either way: a champion in a new seat is the", + "warmest introduction there is, and their replacement at the old", + "account is a relationship nobody owns yet.", + ] + .filter(Boolean) + .join("\n"); + + const meta = { + source: "job-change", + from: change.from, + to: change.to, + }; + + const noteId = await writeTimelineNote(contactId, subject, body, meta); + + const taskId = contact.ownerId + ? await writeOwnerTask( + contactId, + subject, + body, + daysFromNow(JOB_CHANGE.ownerTaskDueDays), + meta, + ) + : null; + + if (moveToCompanyId) { + await db.contact.update({ + where: { id: contactId }, + data: { companyId: moveToCompanyId }, + }); + } + + return { + raised: true, + from: change.from, + to: change.to, + moved: Boolean(moveToCompanyId), + ownerNotified: taskId !== null, + noteId, + taskId, + }; +} diff --git a/apps/agent/agent/lib/lifecycle-advance.ts b/apps/agent/agent/lib/lifecycle-advance.ts new file mode 100644 index 000000000..4ba90c561 --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-advance.ts @@ -0,0 +1,192 @@ +import { AGENT_ACTION_TYPES } from "./agent-actions"; +import type { AgentManifest } from "./agent-manifest"; +import { parseAgentManifest } from "./agent-manifest"; +import type { DraftAgentInput, DraftTrigger } from "./builder-runtime"; + +export const ADVANCE_LIFECYCLE_ROLE = "advance" as const; + +export const ADVANCE_SPECIALIST_NAME = "Advance"; + +export const ADVANCE_SPECIALIST_DESCRIPTION = + "Recommend the next stage and next step for open deals using CRM evidence. Recommend only. Never mutate stage unattended."; + +export const ADVANCE_SPECIALIST_INSTRUCTIONS = `You are the Advance specialist for this CRM team agent. + +Your job is to move open deals forward by recommending the next stage and the next human step. + +Rules: +1. Read only CRM records in the approved scope and connected sources listed in the run. +2. Focus on open deals and their linked contacts. Base every claim on CRM evidence you read in this run. +3. Do not invent stakeholders, activity, or stage history that is not in the record. +4. Detect stall from last activity and deal fields. State the gap in plain language. +5. Recommend a next stage only when evidence supports it. Write that recommendation as a CRM task or note. Never change deal stage yourself. +6. Recommend a concrete next step for the owner: who to contact, what to ask, or what is missing. +7. Write only approved CRM notes and tasks. Put the recommendation, the reason, and the evidence references in that note or task. +8. Finish with run.summary. State the recommended stage or next step and what you wrote. +9. Never send email, SMS, or any external outreach. Never promise that a message was delivered. +10. Never change deal stage, amount, ownership, or close state. +11. Stop after one recommendation cycle on the triggering or selected deal. +`; + +const ADVANCE_STALL_INTERVAL_MINUTES = 24 * 60; + +const ADVANCE_TRIGGERS: DraftTrigger[] = [ + { + type: "MANUAL", + name: "Advance selected deal", + summary: "Run on demand for a tagged open deal", + }, + { + type: "EVENT", + name: "When a deal is opened", + summary: "Recommend the first next step for a new open deal", + event: "deal.opened", + }, + { + type: "EVENT", + name: "When a deal stage changes", + summary: "Recommend the next step after a stage change", + event: "deal.stage.changed", + }, + { + type: "SCHEDULE", + name: "Daily stall check", + summary: "Scan open deals for stalled next steps", + intervalMinutes: ADVANCE_STALL_INTERVAL_MINUTES, + }, +]; + +const ADVANCE_ACTIONS: DraftAgentInput["actions"] = [ + { + type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + provider: "crm", + summary: "Record the next-step recommendation as a note or owner task", + activityTypes: ["NOTE", "TASK"], + }, + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize the recommended stage, next step, and evidence", + }, +]; + +export const ADVANCE_RECOMMEND_ONLY_ACTION_TYPES = [ + AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + AGENT_ACTION_TYPES.RUN_SUMMARY, +] as const; + +export function isAdvanceRecommendOnlyActionType(type: string): boolean { + return (ADVANCE_RECOMMEND_ONLY_ACTION_TYPES as readonly string[]).includes( + type, + ); +} + +export function assertAdvanceRecommendOnlyActions( + actions: ReadonlyArray<{ type: string }>, +): void { + for (const action of actions) { + if (!isAdvanceRecommendOnlyActionType(action.type)) { + throw new Error( + `Advance specialist forbids action type ${action.type}. Recommend-only CRM note/task and run summary are allowed.`, + ); + } + } +} + +export type AdvanceDraftOptions = { + recordScope?: "SELECTED" | "WORKSPACE"; + resources?: DraftAgentInput["resources"]; + name?: string; + description?: string; + instructions?: string; + now?: string; +}; + +export function advanceSpecialistDraft( + options: AdvanceDraftOptions = {}, +): DraftAgentInput { + const recordScope = options.recordScope ?? "WORKSPACE"; + const resources = options.resources ?? []; + const recordResources = resources.filter( + (resource) => resource.kind !== "integration", + ); + + if (recordScope === "SELECTED" && recordResources.length === 0) { + throw new Error("Selected Advance draft needs at least one CRM resource."); + } + if (recordScope === "WORKSPACE" && recordResources.length > 0) { + throw new Error("Workspace Advance draft cannot list selected records."); + } + + const triggers = + recordScope === "SELECTED" + ? ADVANCE_TRIGGERS.filter((trigger) => trigger.type === "MANUAL") + : ADVANCE_TRIGGERS.map((trigger) => + trigger.type === "SCHEDULE" + ? { + ...trigger, + nextRunAt: options.now ?? new Date().toISOString(), + } + : trigger, + ); + + const draft: DraftAgentInput = { + name: options.name ?? ADVANCE_SPECIALIST_NAME, + description: options.description ?? ADVANCE_SPECIALIST_DESCRIPTION, + instructions: options.instructions ?? ADVANCE_SPECIALIST_INSTRUCTIONS, + lifecycleRole: ADVANCE_LIFECYCLE_ROLE, + triggers, + recordScope, + resources, + actions: ADVANCE_ACTIONS, + access: [ + recordScope === "WORKSPACE" + ? "Read workspace CRM records" + : "Read selected CRM records", + "Write notes on CRM records", + "Create tasks on CRM records", + ], + }; + + assertAdvanceRecommendOnlyActions(draft.actions); + return draft; +} + +export function advanceSpecialistManifest( + options: AdvanceDraftOptions = {}, +): AgentManifest { + const draft = advanceSpecialistDraft(options); + const now = options.now ?? new Date().toISOString(); + const manifest = { + kind: "crm-team-agent", + name: draft.name, + description: draft.description, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? now, + } + : trigger.type === "EVENT" + ? { event: trigger.event } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: + draft.recordScope === "WORKSPACE" + ? "Workspace open deals for advance recommendations" + : "Selected deals for manual advance recommendations", + resources: draft.resources, + }, + actions: draft.actions, + access: draft.access, + }; + + return parseAgentManifest(manifest); +} diff --git a/apps/agent/agent/lib/lifecycle-close.ts b/apps/agent/agent/lib/lifecycle-close.ts new file mode 100644 index 000000000..ebac4caff --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-close.ts @@ -0,0 +1,168 @@ +import { AGENT_ACTION_TYPES } from "./agent-actions"; +import type { AgentManifest } from "./agent-manifest"; +import { parseAgentManifest } from "./agent-manifest"; +import type { DraftAgentInput, DraftTrigger } from "./builder-runtime"; + +export const CLOSE_LIFECYCLE_ROLE = "close" as const; + +export const CLOSE_SPECIALIST_NAME = "Close"; + +export const CLOSE_SPECIALIST_DESCRIPTION = + "Win/loss hygiene, handoff notes, and closed-deal checklist under seller rules. Recommend only. Never send outreach or reopen deals."; + +export const CLOSE_SPECIALIST_INSTRUCTIONS = `You are the Close specialist for this CRM team agent. + +Your job is win/loss hygiene, handoff notes, closed-won or closed-lost checklist items, and disqualify recommendations under seller rules. + +Rules: +1. Read only CRM records in the approved scope and connected sources listed in the run. +2. Base every claim on CRM evidence you read in this run. Do not invent outcomes, revenue, or reasons. +3. Treat seller policy as external human config when available. If seller rules are missing, say so and stop inventing policy. +4. Produce a clear close recommendation: closed-won checklist, closed-lost or disqualify reason, handoff notes for the next owner, or needs human judgment. +5. Write only approved CRM notes and tasks. Put the recommendation, the reason, and the evidence references in that note or task. +6. Finish with run.summary. State the recommendation and what you wrote. +7. Never send email, SMS, Slack, or any external outreach. Never promise that a message was delivered. +8. Never reopen a deal. Never change deal stage, amount, currency, or ownership. Never write finance fields. +9. Stop after one decision cycle on the triggering or selected record. +`; + +const CLOSE_TRIGGERS: DraftTrigger[] = [ + { + type: "MANUAL", + name: "Close selected deal", + summary: "Run on demand for a tagged closed or closing deal", + }, + { + type: "EVENT", + name: "When a deal is closed", + summary: "Win/loss hygiene and handoff notes for a closed deal", + event: "deal.closed", + }, +]; + +const CLOSE_ACTIONS: DraftAgentInput["actions"] = [ + { + type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + provider: "crm", + summary: "Record the close recommendation as a note or owner task", + activityTypes: ["NOTE", "TASK"], + }, + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize the close recommendation and evidence", + }, +]; + +export const CLOSE_RECOMMEND_ONLY_ACTION_TYPES = [ + AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + AGENT_ACTION_TYPES.RUN_SUMMARY, +] as const; + +export function isCloseRecommendOnlyActionType(type: string): boolean { + return (CLOSE_RECOMMEND_ONLY_ACTION_TYPES as readonly string[]).includes( + type, + ); +} + +export function assertCloseRecommendOnlyActions( + actions: ReadonlyArray<{ type: string }>, +): void { + for (const action of actions) { + if (!isCloseRecommendOnlyActionType(action.type)) { + throw new Error( + `Close specialist forbids action type ${action.type}. Recommend-only CRM note/task and run summary are allowed.`, + ); + } + } +} + +export type CloseDraftOptions = { + recordScope?: "SELECTED" | "WORKSPACE"; + resources?: DraftAgentInput["resources"]; + name?: string; + description?: string; + instructions?: string; +}; + +export function closeSpecialistDraft( + options: CloseDraftOptions = {}, +): DraftAgentInput { + const recordScope = options.recordScope ?? "SELECTED"; + const resources = options.resources ?? []; + const recordResources = resources.filter( + (resource) => resource.kind !== "integration", + ); + + if (recordScope === "SELECTED" && recordResources.length === 0) { + throw new Error("Selected Close draft needs at least one CRM resource."); + } + if (recordScope === "WORKSPACE" && recordResources.length > 0) { + throw new Error("Workspace Close draft cannot list selected records."); + } + + const triggers = + recordScope === "SELECTED" + ? CLOSE_TRIGGERS.filter((trigger) => trigger.type === "MANUAL") + : CLOSE_TRIGGERS; + + const draft: DraftAgentInput = { + name: options.name ?? CLOSE_SPECIALIST_NAME, + description: options.description ?? CLOSE_SPECIALIST_DESCRIPTION, + instructions: options.instructions ?? CLOSE_SPECIALIST_INSTRUCTIONS, + lifecycleRole: CLOSE_LIFECYCLE_ROLE, + triggers, + recordScope, + resources, + actions: CLOSE_ACTIONS, + access: [ + recordScope === "WORKSPACE" + ? "Read workspace CRM records" + : "Read selected CRM records", + "Write notes on CRM records", + "Create tasks on CRM records", + ], + }; + + assertCloseRecommendOnlyActions(draft.actions); + return draft; +} + +export function closeSpecialistManifest( + options: CloseDraftOptions = {}, +): AgentManifest { + const draft = closeSpecialistDraft(options); + const now = new Date().toISOString(); + const manifest = { + kind: "crm-team-agent", + name: draft.name, + description: draft.description, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? now, + } + : trigger.type === "EVENT" + ? { event: trigger.event } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: + draft.recordScope === "WORKSPACE" + ? "Workspace CRM records for closed-deal hygiene" + : "Selected CRM records for manual close handoff", + resources: draft.resources, + }, + actions: draft.actions, + access: draft.access, + }; + + return parseAgentManifest(manifest); +} diff --git a/apps/agent/agent/lib/lifecycle-engage.ts b/apps/agent/agent/lib/lifecycle-engage.ts new file mode 100644 index 000000000..f59960283 --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-engage.ts @@ -0,0 +1,174 @@ +import { AGENT_ACTION_TYPES } from "./agent-actions"; +import type { AgentManifest } from "./agent-manifest"; +import { parseAgentManifest } from "./agent-manifest"; +import type { DraftAgentInput, DraftTrigger } from "./builder-runtime"; + +export const ENGAGE_LIFECYCLE_ROLE = "engage" as const; + +export const ENGAGE_SPECIALIST_NAME = "Engage"; + +export const ENGAGE_SPECIALIST_DESCRIPTION = + "Recommend the next outreach for a selected person or company. Queue notes and tasks only. Never send email or SMS."; + +export const ENGAGE_SPECIALIST_INSTRUCTIONS = `You are the Engage specialist for this CRM team agent. + +Your job is to recommend the next outreach and queue the work for a human. + +Rules: +1. Read only CRM records in the approved scope and connected sources listed in the run. +2. Base every claim on CRM evidence you read in this run. Do not invent history, titles, or relationship facts. +3. Treat seller policy as external human config when available. If seller rules are missing, say so and stop inventing policy. +4. Recommend one next outreach: channel, audience, subject or opener, body draft, and why. +5. Write only approved CRM notes and tasks. Put the recommendation, the reason, and the evidence references in that note or task. +6. Finish with run.summary. State the recommendation and what you queued. +7. Never send email, SMS, LinkedIn messages, or any external outreach. Never promise that a message was delivered. +8. Never change deal stage or ownership. +9. Stop after one recommendation cycle on the triggering or selected record. +`; + +const ENGAGE_TRIGGERS: DraftTrigger[] = [ + { + type: "MANUAL", + name: "Recommend next outreach", + summary: "Run on demand for a tagged contact, company, or deal", + }, + { + type: "EVENT", + name: "When a deal is opened", + summary: "Recommend first-touch outreach for a newly opened deal", + event: "deal.opened", + }, + { + type: "EVENT", + name: "When a deal stage changes", + summary: "Recommend follow-up outreach after a stage change", + event: "deal.stage.changed", + }, +]; + +const ENGAGE_ACTIONS: DraftAgentInput["actions"] = [ + { + type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + provider: "crm", + summary: "Queue the outreach recommendation as a note or owner task", + activityTypes: ["NOTE", "TASK"], + }, + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize the recommended outreach and what was queued", + }, +]; + +export const ENGAGE_RECOMMEND_ONLY_ACTION_TYPES = [ + AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + AGENT_ACTION_TYPES.RUN_SUMMARY, +] as const; + +export function isEngageRecommendOnlyActionType(type: string): boolean { + return (ENGAGE_RECOMMEND_ONLY_ACTION_TYPES as readonly string[]).includes( + type, + ); +} + +export function assertEngageRecommendOnlyActions( + actions: ReadonlyArray<{ type: string }>, +): void { + for (const action of actions) { + if (!isEngageRecommendOnlyActionType(action.type)) { + throw new Error( + `Engage specialist forbids action type ${action.type}. Recommend-only CRM note/task and run summary are allowed.`, + ); + } + } +} + +export type EngageDraftOptions = { + recordScope?: "SELECTED" | "WORKSPACE"; + resources?: DraftAgentInput["resources"]; + name?: string; + description?: string; + instructions?: string; +}; + +export function engageSpecialistDraft( + options: EngageDraftOptions = {}, +): DraftAgentInput { + const recordScope = options.recordScope ?? "SELECTED"; + const resources = options.resources ?? []; + const recordResources = resources.filter( + (resource) => resource.kind !== "integration", + ); + + if (recordScope === "SELECTED" && recordResources.length === 0) { + throw new Error("Selected Engage draft needs at least one CRM resource."); + } + if (recordScope === "WORKSPACE" && recordResources.length > 0) { + throw new Error("Workspace Engage draft cannot list selected records."); + } + + const triggers = + recordScope === "SELECTED" + ? ENGAGE_TRIGGERS.filter((trigger) => trigger.type === "MANUAL") + : ENGAGE_TRIGGERS; + + const draft: DraftAgentInput = { + name: options.name ?? ENGAGE_SPECIALIST_NAME, + description: options.description ?? ENGAGE_SPECIALIST_DESCRIPTION, + instructions: options.instructions ?? ENGAGE_SPECIALIST_INSTRUCTIONS, + lifecycleRole: ENGAGE_LIFECYCLE_ROLE, + triggers, + recordScope, + resources, + actions: ENGAGE_ACTIONS, + access: [ + recordScope === "WORKSPACE" + ? "Read workspace CRM records" + : "Read selected CRM records", + "Write notes on CRM records", + "Create tasks on CRM records", + ], + }; + + assertEngageRecommendOnlyActions(draft.actions); + return draft; +} + +export function engageSpecialistManifest( + options: EngageDraftOptions = {}, +): AgentManifest { + const draft = engageSpecialistDraft(options); + const now = new Date().toISOString(); + const manifest = { + kind: "crm-team-agent", + name: draft.name, + description: draft.description, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? now, + } + : trigger.type === "EVENT" + ? { event: trigger.event } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: + draft.recordScope === "WORKSPACE" + ? "Workspace CRM records for outreach recommendations" + : "Selected CRM records for manual outreach recommendations", + resources: draft.resources, + }, + actions: draft.actions, + access: draft.access, + }; + + return parseAgentManifest(manifest); +} diff --git a/apps/agent/agent/lib/lifecycle-qualify.ts b/apps/agent/agent/lib/lifecycle-qualify.ts new file mode 100644 index 000000000..501248946 --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-qualify.ts @@ -0,0 +1,174 @@ +import { AGENT_ACTION_TYPES } from "./agent-actions"; +import type { AgentManifest } from "./agent-manifest"; +import { parseAgentManifest } from "./agent-manifest"; +import type { DraftAgentInput, DraftTrigger } from "./builder-runtime"; + +export const QUALIFY_LIFECYCLE_ROLE = "qualify" as const; + +export const QUALIFY_SPECIALIST_NAME = "Qualify"; + +export const QUALIFY_SPECIALIST_DESCRIPTION = + "Decide if a contact or company is worth pipeline time using CRM evidence and seller rules. Recommend only. Never send outreach."; + +export const QUALIFY_SPECIALIST_INSTRUCTIONS = `You are the Qualify specialist for this CRM team agent. + +Your job is to decide whether a contact or company is worth pipeline time. + +Rules: +1. Read only CRM records in the approved scope and connected sources listed in the run. +2. Base every claim on CRM evidence you read in this run. Do not invent firmographics, titles, or fit scores. +3. Treat seller policy as external human config when available. If seller rules are missing, say so and stop inventing policy. +4. Produce a clear qualification decision: worth pursuing, not a fit, or needs human judgment. +5. Write only approved CRM notes and tasks. Put the decision, the reason, and the evidence references in that note or task. +6. Finish with run.summary. State the decision and what you wrote. +7. Never send email, SMS, or any external outreach. Never promise that a message was delivered. +8. Never change deal stage or ownership. +9. Stop after one decision cycle on the triggering or selected record. +`; + +const QUALIFY_TRIGGERS: DraftTrigger[] = [ + { + type: "MANUAL", + name: "Qualify selected record", + summary: "Run on demand for a tagged contact or company", + }, + { + type: "EVENT", + name: "When a contact is created", + summary: "Qualify a new contact for pipeline fit", + event: "contact.created", + }, + { + type: "EVENT", + name: "When a company is created", + summary: "Qualify a new company for pipeline fit", + event: "company.created", + }, +]; + +const QUALIFY_ACTIONS: DraftAgentInput["actions"] = [ + { + type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + provider: "crm", + summary: "Record the qualification decision as a note or owner task", + activityTypes: ["NOTE", "TASK"], + }, + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize the qualification decision and evidence", + }, +]; + +export const QUALIFY_RECOMMEND_ONLY_ACTION_TYPES = [ + AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + AGENT_ACTION_TYPES.RUN_SUMMARY, +] as const; + +export function isRecommendOnlyActionType(type: string): boolean { + return (QUALIFY_RECOMMEND_ONLY_ACTION_TYPES as readonly string[]).includes( + type, + ); +} + +export function assertQualifyRecommendOnlyActions( + actions: ReadonlyArray<{ type: string }>, +): void { + for (const action of actions) { + if (!isRecommendOnlyActionType(action.type)) { + throw new Error( + `Qualify specialist forbids action type ${action.type}. Recommend-only CRM note/task and run summary are allowed.`, + ); + } + } +} + +export type QualifyDraftOptions = { + recordScope?: "SELECTED" | "WORKSPACE"; + resources?: DraftAgentInput["resources"]; + name?: string; + description?: string; + instructions?: string; +}; + +export function qualifySpecialistDraft( + options: QualifyDraftOptions = {}, +): DraftAgentInput { + const recordScope = options.recordScope ?? "WORKSPACE"; + const resources = options.resources ?? []; + const recordResources = resources.filter( + (resource) => resource.kind !== "integration", + ); + + if (recordScope === "SELECTED" && recordResources.length === 0) { + throw new Error("Selected Qualify draft needs at least one CRM resource."); + } + if (recordScope === "WORKSPACE" && recordResources.length > 0) { + throw new Error("Workspace Qualify draft cannot list selected records."); + } + + const triggers = + recordScope === "SELECTED" + ? QUALIFY_TRIGGERS.filter((trigger) => trigger.type === "MANUAL") + : QUALIFY_TRIGGERS; + + const draft: DraftAgentInput = { + name: options.name ?? QUALIFY_SPECIALIST_NAME, + description: options.description ?? QUALIFY_SPECIALIST_DESCRIPTION, + instructions: options.instructions ?? QUALIFY_SPECIALIST_INSTRUCTIONS, + lifecycleRole: QUALIFY_LIFECYCLE_ROLE, + triggers, + recordScope, + resources, + actions: QUALIFY_ACTIONS, + access: [ + recordScope === "WORKSPACE" + ? "Read workspace CRM records" + : "Read selected CRM records", + "Write notes on CRM records", + "Create tasks on CRM records", + ], + }; + + assertQualifyRecommendOnlyActions(draft.actions); + return draft; +} + +export function qualifySpecialistManifest( + options: QualifyDraftOptions = {}, +): AgentManifest { + const draft = qualifySpecialistDraft(options); + const now = new Date().toISOString(); + const manifest = { + kind: "crm-team-agent", + name: draft.name, + description: draft.description, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? now, + } + : trigger.type === "EVENT" + ? { event: trigger.event } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: + draft.recordScope === "WORKSPACE" + ? "Workspace CRM records for intake qualification" + : "Selected CRM records for manual qualification", + resources: draft.resources, + }, + actions: draft.actions, + access: draft.access, + }; + + return parseAgentManifest(manifest); +} diff --git a/apps/agent/agent/lib/lifecycle-roles.ts b/apps/agent/agent/lib/lifecycle-roles.ts new file mode 100644 index 000000000..9ebe5a46a --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-roles.ts @@ -0,0 +1,15 @@ +export const LIFECYCLE_ROLES = [ + "qualify", + "engage", + "advance", + "close", +] as const; + +export type LifecycleRole = (typeof LIFECYCLE_ROLES)[number]; + +export function isLifecycleRole(value: unknown): value is LifecycleRole { + return ( + typeof value === "string" && + (LIFECYCLE_ROLES as readonly string[]).includes(value) + ); +} diff --git a/apps/agent/agent/lib/linkedin-candidates.ts b/apps/agent/agent/lib/linkedin-candidates.ts new file mode 100644 index 000000000..c0b1e866d --- /dev/null +++ b/apps/agent/agent/lib/linkedin-candidates.ts @@ -0,0 +1,91 @@ +import { type SearchResult, search } from "./context-dev"; +import { slugFromProfileUrl } from "./linkdapi"; +import { searchTerms } from "./names"; + +const MAX_CANDIDATES = 5; + +export function linkedInSlugsFromText(text: string): string[] { + const slugs: string[] = []; + + for (const match of text.matchAll( + /linkedin\.com\/in\/([A-Za-z0-9\-_%]+)/gi, + )) { + const raw = match[1]; + if (!raw) continue; + + const slug = decodeURIComponent(raw).replace(/\/+$/, "").toLowerCase(); + if (slug && !slugs.includes(slug)) slugs.push(slug); + } + + return slugs; +} + +export function linkedInSlugsFromResults(results: SearchResult[]): string[] { + const slugs: string[] = []; + + const add = (slug: string | null) => { + if (!slug) return; + const normalised = slug.replace(/\/+$/, "").toLowerCase(); + if (normalised && !slugs.includes(normalised)) slugs.push(normalised); + }; + + for (const result of results) { + add(slugFromProfileUrl(result.url)); + + const haystack = [result.title, result.description, result.markdown] + .filter(Boolean) + .join("\n"); + + for (const slug of linkedInSlugsFromText(haystack)) add(slug); + } + + return slugs; +} + +export function linkedInSearchQuery(term: string, companyName: string): string { + const company = companyName.trim(); + const quoted = company.includes(" ") ? `"${company}"` : company; + return `site:linkedin.com/in ${term} ${quoted}`.trim(); +} + +export async function findLinkedInCandidates( + email: string, + companyName: string, +): Promise<{ + searchedFor: string[]; + candidateSlugs: string[]; + note?: string; +}> { + const local = email.split("@")[0] ?? ""; + const terms = searchTerms(local); + const slugs: string[] = []; + + for (const term of terms) { + const outcome = await search(linkedInSearchQuery(term, companyName), { + limit: 10, + }); + + if (outcome.outcome !== "found") { + return { + searchedFor: terms, + candidateSlugs: [], + note: outcome.reason, + }; + } + + for (const slug of linkedInSlugsFromResults(outcome.results)) { + if (!slugs.includes(slug)) slugs.push(slug); + } + + if (slugs.length >= MAX_CANDIDATES) break; + } + + return { + searchedFor: terms, + candidateSlugs: slugs.slice(0, MAX_CANDIDATES), + note: + slugs.length === 0 + ? "No LinkedIn candidates. A miss stays a miss — do not invent a profile." + : "Unverified. Each slug must be checked with get_linkedin_profile.", + }; +} diff --git a/apps/agent/agent/lib/perplexity.ts b/apps/agent/agent/lib/perplexity.ts index 4f1c235da..e7377d87a 100644 --- a/apps/agent/agent/lib/perplexity.ts +++ b/apps/agent/agent/lib/perplexity.ts @@ -80,31 +80,3 @@ export async function ask( clearTimeout(timer); } } - -export async function findProfileUrls( - terms: string[], - companyName: string, -): Promise { - const slugs: string[] = []; - - for (const term of terms) { - const answer = await ask( - `Find the LinkedIn profile of the person called "${term}" who works at ${companyName}. Reply with their profile URL only.`, - { domains: ["linkedin.com"] }, - ); - - if (!answer.ok) continue; - - const haystack = [answer.data.text, ...answer.data.citations].join(" "); - for (const match of haystack.matchAll( - /linkedin\.com\/in\/([A-Za-z0-9\-_%]+)/g, - )) { - const slug = match[1]; - if (slug && !slugs.includes(slug)) slugs.push(slug); - } - - if (slugs.length > 0) break; - } - - return slugs; -} diff --git a/apps/agent/agent/lib/recheck-config.ts b/apps/agent/agent/lib/recheck-config.ts new file mode 100644 index 000000000..0ce3a1ae4 --- /dev/null +++ b/apps/agent/agent/lib/recheck-config.ts @@ -0,0 +1,19 @@ +const DAY_MS = 24 * 60 * 60 * 1000; + +export const RECHECK = { + championDays: 14, + namedDays: 90, + emptyDays: 365, + baselineDays: 30, + minDays: 1, + maxDays: 730, + defaultBudget: 4, +} as const; + +export const JOB_CHANGE = { + ownerTaskDueDays: 2, +} as const; + +export function daysFromNow(days: number, from = Date.now()): Date { + return new Date(from + days * DAY_MS); +} diff --git a/apps/agent/agent/lib/research-instructions.ts b/apps/agent/agent/lib/research-instructions.ts index b29b5781b..2ffb17cff 100644 --- a/apps/agent/agent/lib/research-instructions.ts +++ b/apps/agent/agent/lib/research-instructions.ts @@ -22,5 +22,7 @@ Only vendor calls spend the session research budget. When it is gone, write up what you have and stop, or schedule a recheck when another look is justified. Load identity-matching before deciding whether a candidate is the same person, -evidence before recording facts, writing-a-brief before a background brief, and -data-boundaries before moving data outside the CRM.`; +evidence before recording facts, writing-a-brief before a background brief, +meeting-prep for an upcoming-meeting task, job-change when an employer fact +moves or a recheck finds a new role, and data-boundaries before moving data +outside the CRM.`; diff --git a/apps/agent/agent/lib/stalled-deal.ts b/apps/agent/agent/lib/stalled-deal.ts new file mode 100644 index 000000000..ab83b86f3 --- /dev/null +++ b/apps/agent/agent/lib/stalled-deal.ts @@ -0,0 +1,66 @@ +import { ActivityType, db } from "@crm/db"; +import { isClosedStage } from "@crm/db/deal-stage"; +import { + daysInactive, + STALLED_DEAL, + stallTaskSubject, +} from "@crm/db/stalled-deals"; +import type { LeasedTask } from "./tasks"; + +export async function flagStalledDeal(task: LeasedTask): Promise { + if (!task.dealId) return "No deal on this task."; + + const deal = await db.deal.findUnique({ + where: { id: task.dealId }, + select: { + id: true, + name: true, + stage: true, + companyId: true, + ownerId: true, + createdAt: true, + lastActivityAt: true, + }, + }); + + if (!deal) return "The deal this names is gone."; + if (isClosedStage(deal.stage)) return "The deal is closed."; + + const existing = await db.activity.findFirst({ + where: { + dealId: deal.id, + type: ActivityType.TASK, + completedAt: null, + meta: { path: ["source"], equals: STALLED_DEAL.source }, + }, + select: { id: true }, + }); + + if (existing) return "Owner already has an open stalled-deal task."; + + const now = new Date(); + const days = daysInactive({ + lastActivityAt: deal.lastActivityAt, + createdAt: deal.createdAt, + now, + }); + + await db.activity.create({ + data: { + type: ActivityType.TASK, + subject: stallTaskSubject(deal.name), + body: task.reason, + occurredAt: now, + dueAt: now, + companyId: deal.companyId, + dealId: deal.id, + createdById: deal.ownerId, + meta: { + source: STALLED_DEAL.source, + daysInactive: days, + }, + }, + }); + + return "Raised an owner task for the stalled deal."; +} diff --git a/apps/agent/agent/skills/evidence.md b/apps/agent/agent/skills/evidence.md index 6329dc091..936c79757 100644 --- a/apps/agent/agent/skills/evidence.md +++ b/apps/agent/agent/skills/evidence.md @@ -52,12 +52,21 @@ arithmetic this system exists to avoid. ## What happens next, so you can stop guessing about it -- Primary source and a high score → **written to the record.** -- Otherwise → **stored as a suggestion** under the empty field, for a rep. -- Weak → kept but never shown. -- Nothing → not stored. - -A suggestion is a good outcome. It is often the *correct* outcome: four Marchettis -work at Fernhill and a human settles that in three seconds. Do not go looking for +The write path is `record_fact` / `identify_contact`. It scores evidence, then +applies `fillsBlank` — you do not choose the outcome. + +- Below the floor (no band) → **not stored.** A miss stays a miss. +- Clears the floor and the field is blank (or a derived placeholder name) → + **written**, any band that cleared the floor. Approving a sourced guess into + an empty field is free for the rep; the system does that write. +- Clears the floor but a human or prior value already fills the field, and the + band is not VERIFIED → **proposal** for a rep. Only VERIFIED replaces a filled + field. +- A human-typed value always wins; dismissed values are never re-offered. + +`employer-only` alone is deliberately below the floor. That is how a colleague +at the same company is kept off the record. + +A proposal is a good outcome when the field already has a value. Do not hunt for extra evidence to push a claim over a line — that is how a wrong answer gets dressed up as a right one. diff --git a/apps/agent/agent/skills/identity-matching.md b/apps/agent/agent/skills/identity-matching.md index e5ff9f66f..f38db20a5 100644 --- a/apps/agent/agent/skills/identity-matching.md +++ b/apps/agent/agent/skills/identity-matching.md @@ -23,6 +23,10 @@ the profile. That is the shape of every match: guess where to look, never what you will find. +LinkDAPI is an **enricher, not a finder**. It reads a known slug superbly and +must never be used to search people by name (that search returns strangers). +Finding the slug is Context web search (`site:linkedin.com/in`). + ## The procedure 0. **`read_crm_history` first.** It is free and it is often decisive. If they @@ -30,20 +34,22 @@ That is the shape of every match: guess where to look, never what you will find. evidence available anywhere — `crm.thread-reply` — and a signature block may hand you their title as well. Start every match here, not at a search engine. 1. **`resolve_linkedin_profile`** with the email and company. It decomposes the - local part and returns candidate slugs. These are leads, not answers. + local part, runs Context `site:linkedin.com/in` search, and returns candidate + slugs. These are leads, not answers. An empty list is a finished answer. 2. **`get_linkedin_profile`** on each candidate, passing the email, company name - and domain — **and the `contactId`**. It returns the profile *and a verdict*. - Passing the id is what lets it copy their photograph, which it does only if - the verdict comes back positive, in code, without asking you. Leaving it out - costs the contact their picture and saves nothing. + and domain — **and the `contactId`**. It returns the profile, a code verdict, + and a ready **`evidence` array**. Passing the id is what lets it copy their + photograph, which it does only if the verdict comes back positive, in code, + without asking you. Leaving it out costs the contact their picture and saves + nothing. 3. **Read the verdict, not the profile.** It checks two things: - `employerMatches` — a current position matches the company we have. - `nameMatches` — the real name is consistent with the email local part (`y` + `okonkwo` → Tomi Okonkwo). 4. **Both, or it is not them.** One of the two is not a weaker match, it is a - different person who happens to share something. + different person who happens to share something. `isSamePerson` is false. 5. If no candidate passes, **stop**. Leaving "Pmarchetti" in the CRM is the correct - outcome when you do not know. + outcome when you do not know. A miss stays a miss. Somebody whose LinkedIn URL is **already on the record** has been through all of this before. Do not re-run it to get a picture — `fetch_contact_photo` is one @@ -51,20 +57,26 @@ call, and the URL sitting there is the verification. ## Reporting the match -Call `identify_contact` with what you actually saw: +Only call `identify_contact` when the tool says to. Pass **the `evidence` array +returned by `get_linkedin_profile`** (or CRM kinds you actually observed). Do not +invent kinds to raise a band. -| What you have | Evidence to record | What happens | +| What you have | Evidence to record | What the write path does | | --- | --- | --- | -| Both checks pass | `linkedin.employer-and-name` | Written to the record. | -| They replied from that address | `crm.thread-reply` | Written to the record. | -| One check passes | `employer-only`, or the profile as `search.cites-profile` | Offered to a rep as a suggestion. | +| `isSamePerson` true | tool's `linkedin.employer-and-name` | Clears the VERIFIED floor; writes the name when no human owns it. | +| They replied from that address | `crm.thread-reply` | Clears the VERIFIED floor; same write rule. | +| Email shown on the profile | `profile.email-match` | Clears the VERIFIED floor; same write rule. | +| Employer only, name fails | tool's `employer-only` | Below the keep floor. **Not stored.** Stop. | +| Name only, employer fails | empty evidence | Not them. **Do not identify.** Stop. | | Sources disagree | add a `contradiction` entry | Held. Nobody is shown a guess. | -The middle row is the case this exists for. Four Marchettis work at Fernhill; a -human settles that in three seconds, and the old rule — throw away anything -short of certain — meant we paid for that lookup every run and learned nothing -from it. A suggestion is not a failed match. It is the match, handed to the one -person who can finish it. +`record_fact` / `identify_contact` price evidence in code (`lib/evidence.ts`). +You never set a confidence score. Bands and `fillsBlank` decide apply vs propose: + +- Below the floor → not stored (miss stays a miss). +- Clears the floor and the field is blank (or a derived placeholder name) → applied. +- Clears the floor but a value is already filled and the band is not VERIFIED → proposal for a rep. +- A human-typed name always wins over agent evidence. Do not add evidence you did not observe to push a claim over a line. @@ -77,9 +89,11 @@ Do not add evidence you did not observe to push a claim over a line. The surname or the employer has to carry it. - **Perplexity's view of somebody's job title.** It aggregates stale sources; it said "Account Executive L3" for a profile that reads "Growth Specialist at - Fernhill". For identity, the person's own profile wins. + Fernhill". For identity, the person's own profile wins. Perplexity is not the + slug finder. - **A very plausible expansion.** `jsmith` is probably J. Smith. Probably is not a source. +- **LinkDAPI people search.** Broken; returns unrelated people. Never use it. ## When the person genuinely is not findable diff --git a/apps/agent/agent/skills/job-change.md b/apps/agent/agent/skills/job-change.md new file mode 100644 index 000000000..61d029a7b --- /dev/null +++ b/apps/agent/agent/skills/job-change.md @@ -0,0 +1,57 @@ +--- +name: job-change +description: Use when a contact's employer fact changes, or on a recheck pass that compares current role to what the CRM already holds — how to raise the signal without overwriting human data. +--- + +# Job changes + +A champion who moves company is the highest-intent people signal in B2B sales. +Detection is not a separate pipeline. A new applied `employer` fact that +supersedes the previous one *is* the event. + +## Detect + +1. Read the contact (`read_crm_history`) and what you already recorded. +2. Re-read their public profile or work history on the recheck cadence. +3. If the current employer differs from the applied fact, call `record_fact` + with field `employer` and the evidence you observed. +4. When that fact applies and supersedes a previous employer, call + `record_job_change` with the contact id only. + +Do not invent a move from a weak page. No employer change on the facts means +`record_job_change` refuses. That is correct. + +## Raise, do not re-parent unattended + +`record_job_change` always: + +- writes a timeline note on the contact +- creates an owner TASK when the contact has an owner + +It does **not** move the contact to another company unless you pass +`moveToCompanyId`. That argument is approval-gated for a person and **denied** +on automated sessions. Unattended runs omit it. The owner decides whether the +CRM company link changes. + +Never overwrite a field a person typed. Facts already refuse human-owned +columns; company re-parent is the extra gate on this tool. + +## Recheck cadences + +Use `schedule_recheck` with a reason a rep can read: + +| Situation | Days | Reason shape | +| --- | ---: | --- | +| Champion on an open deal | 14 | "a job change here would move the Acme deal" | +| Named contact, no open deal | 90 | "named contact at Fernhill, worth a quiet recheck" | +| Steady job-change feed | 30 | "baseline re-read so an employer move is not missed" | +| Nothing found twice | 365 | "two empty lookups; park until next year" | +| Support / no-reply alias | — | do not schedule | + +Pick the shortest interval that matches the contact's deal weight. A live +champion beats a baseline feed. + +## After the raise + +Schedule the next recheck if the contact still matters. The note and task are +the product; do not email anyone and do not invent a replacement contact. diff --git a/apps/agent/agent/skills/meeting-prep.md b/apps/agent/agent/skills/meeting-prep.md new file mode 100644 index 000000000..370af1231 --- /dev/null +++ b/apps/agent/agent/skills/meeting-prep.md @@ -0,0 +1,40 @@ +--- +name: meeting-prep +description: Use when a meeting-prep task is open — identify first if needed, then write a short Background brief only when identity is trustworthy. +--- + +# Meeting prep + +Calendar sync enqueues a `meeting-prep` task (priority 200) for known contacts +on upcoming meetings who still have no Background panel. Your job is a rep-ready +brief by the night before — or a deliberate empty panel when identity is weak. + +## Order of work + +1. **`read_crm_history` first.** Free, often decisive. Threads and prior + meetings settle identity and titles without a vendor call. +2. **Trustworthy identity, or stop.** Load `identity-matching`. If the contact + is still a placeholder name with no LinkedIn and no applied name, run the + identity path (`resolve_linkedin_profile` → `get_linkedin_profile` → + `identify_contact`) before any brief. +3. **Write only when identity holds.** Call `write_brief` with evidence that + proves who they are *and* supports the claims. The tool refuses garbage + identity in code — do not try to push a weak match over the line. +4. **Shape.** Load `writing-a-brief`. Two or three sentences, third person, + current role first. Empty structured lines beat guessed ones. +5. **Stop.** Nobody is waiting on chat. Record what you found and end. + +## When to write nothing + +- Identity fails both employer and name checks. +- The only material is the job title already on the record. +- Evidence is employer-only, a search hit, or meeting attendance alone. + +An empty Background panel is the correct outcome. A wrong person on the panel +is the failure mode this path exists to prevent. + +## What calendar already did + +Nest only wrote the task row. It does not research, match identity, or score +people. Attendees without a CRM contact are not enqueued. Contacts that already +have a brief are skipped. Horizon is the next seven days. diff --git a/apps/agent/agent/subagents/agent_builder/instructions.md b/apps/agent/agent/subagents/agent_builder/instructions.md index d4321cf79..88fdb8804 100644 --- a/apps/agent/agent/subagents/agent_builder/instructions.md +++ b/apps/agent/agent/subagents/agent_builder/instructions.md @@ -41,6 +41,17 @@ Gmail and Google Calendar are read-only sources when connected. Do not promise email sending, arbitrary webhooks, or any integration the context does not report. +Optional `lifecycleRole` tags a specialist for the sales lifecycle: +`qualify`, `engage`, `advance`, or `close`. Set it when the user asks for that +role. For all four roles, use only `crm.activity.create` and `run.summary`. +Never grant send or Slack actions for those roles. Qualify recommends pipeline +fit with notes and tasks only. Engage recommends the next outreach and queues +notes or tasks only; it never sends email or SMS. Advance recommends stage and +next step as notes and tasks only, and never mutates deal stage. Close +recommends win/loss hygiene, handoff notes, closed checklists, and disqualify +reasons with notes and tasks only. Never reopen deals or write finance fields +for close. + Every executable Slack destination is `chosen` and pinned to an inspected Slack id. When a named person matches exactly one entry in `availableConnections.slackPeople` by CRM name, CRM email, diff --git a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts index 453e3cf39..2b7a0b11f 100644 --- a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts +++ b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts @@ -2,6 +2,7 @@ import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; import { z } from "zod"; import { AGENT_ACTION_TYPES } from "../../../lib/agent-actions"; import type { DraftAgentInput } from "../../../lib/builder-runtime"; +import { LIFECYCLE_ROLES } from "../../../lib/lifecycle-roles"; const recordResource = z.object({ kind: z.enum(["company", "contact", "deal"]), @@ -61,6 +62,7 @@ export const builderDraftToolInput = z.object({ name: z.string().trim().min(1).max(100), description: z.string().trim().min(1).max(320), instructions: z.string().trim().min(40).max(20_000), + lifecycleRole: z.enum(LIFECYCLE_ROLES).optional(), triggers: z.array(trigger).min(1).max(10), recordScope: z.enum(["SELECTED", "WORKSPACE"]), resources: z.array(recordResource).max(30), diff --git a/apps/agent/agent/tools/get_linkedin_profile.ts b/apps/agent/agent/tools/get_linkedin_profile.ts index f52e21753..97bd6a251 100644 --- a/apps/agent/agent/tools/get_linkedin_profile.ts +++ b/apps/agent/agent/tools/get_linkedin_profile.ts @@ -2,13 +2,17 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; import { enabled, unavailable } from "../lib/capabilities"; import { spend } from "../lib/focus"; +import { + identityChecks, + identityEvidence, + identityNextStep, +} from "../lib/identity-verdict"; import { getExperience, getProfile } from "../lib/linkdapi"; -import { looksLikeSameCompany, nameMatchesLocalPart } from "../lib/names"; import { storePortrait } from "../lib/portrait"; export default defineTool({ description: - "Read a LinkedIn profile by slug and check whether it is really the person behind an email address. Returns the profile plus an explicit verdict.", + "Read a LinkedIn profile by slug (LinkDAPI enricher) and check whether it is really the person behind an email address. Returns the profile, a code verdict, and priced evidence. Do not invent evidence kinds — use the evidence array as returned.", inputSchema: z.object({ slug: z.string().describe("The linkedin.com/in/ handle."), email: z.string().describe("The address we are trying to identify."), @@ -48,20 +52,14 @@ export default defineTool({ } const profile = result.data; - const local = email.split("@")[0] ?? ""; - - const employerMatches = profile.positions.some((position) => - looksLikeSameCompany(position.name, companyName, companyDomain), - ); - const nameMatches = nameMatchesLocalPart(profile, local); + const verdict = identityChecks(profile, email, companyName, companyDomain); + const evidence = identityEvidence(profile, verdict, email, companyName); const history = includeHistory && profile.urn ? await getExperience(profile.urn) : null; - const isSamePerson = employerMatches && nameMatches; - const portrait = - contactId && isSamePerson + contactId && verdict.isSamePerson ? await storePortrait({ contactId, sourceUrl: profile.photoUrl, @@ -74,17 +72,9 @@ export default defineTool({ profile, experience: history?.ok ? history.data : null, photo: portrait ?? undefined, - verdict: { - employerMatches, - nameMatches, - isSamePerson, - confidence: - employerMatches && nameMatches - ? ("high" as const) - : employerMatches || nameMatches - ? ("medium" as const) - : ("low" as const), - }, + verdict, + evidence, + next: identityNextStep(verdict), }; }, }); diff --git a/apps/agent/agent/tools/identify_contact.ts b/apps/agent/agent/tools/identify_contact.ts index e31f7d281..c68013185 100644 --- a/apps/agent/agent/tools/identify_contact.ts +++ b/apps/agent/agent/tools/identify_contact.ts @@ -8,7 +8,7 @@ import { assertResearchPurpose } from "../lib/session-purpose"; export default defineTool({ description: - "Put a verified name to a CRM contact, with the evidence for it. Strong evidence writes the name; anything less becomes a suggestion for a rep. Never overwrites a name a person supplied.", + "Put a name to a CRM contact with priced evidence. Prefer the evidence array from get_linkedin_profile. VERIFIED or a blank/placeholder name writes through; weaker evidence against a filled name becomes a proposal; below the floor is not stored. Never overwrites a name a person typed. Never invent evidence kinds.", inputSchema: z.object({ contactId: z.string(), fullName: z.string().describe("Exactly as the source writes it."), diff --git a/apps/agent/agent/tools/record_job_change.ts b/apps/agent/agent/tools/record_job_change.ts index d7fdbb726..7b759c9a9 100644 --- a/apps/agent/agent/tools/record_job_change.ts +++ b/apps/agent/agent/tools/record_job_change.ts @@ -1,15 +1,13 @@ -import { db } from "@crm/db"; import { defineTool } from "eve/tools"; import { z } from "zod"; -import { sensitiveWrite } from "../lib/approval"; -import { writeTimelineNote } from "../lib/crm"; -import { lastEmployerChange } from "../lib/facts"; +import { sensitiveWhen } from "../lib/approval"; import { focusOn } from "../lib/focus"; +import { raiseJobChange } from "../lib/job-change"; import { assertResearchPurpose } from "../lib/session-purpose"; export default defineTool({ description: - "Raise a job change on a contact's timeline and task their owner. Reads the change from the facts already recorded; call it after recording a new employer.", + "Raise a job change on a contact's timeline and task their owner. Reads the change from the facts already recorded; call it after recording a new employer. Re-parenting to a CRM company needs a person; omit moveToCompanyId on unattended runs.", inputSchema: z.object({ contactId: z.string(), moveToCompanyId: z @@ -19,65 +17,14 @@ export default defineTool({ "Only when the new employer is already a company in the CRM and a person has approved the move.", ), }), - approval: sensitiveWrite( + approval: sensitiveWhen( + (input: { moveToCompanyId?: string } | undefined) => + Boolean(input?.moveToCompanyId), "Raise the change without `moveToCompanyId` — the alert lands on the timeline and their owner decides whether to move them.", ), async execute({ contactId, moveToCompanyId }, ctx) { assertResearchPurpose(ctx); focusOn({ contactId }); - - const change = await lastEmployerChange(contactId); - if (!change) { - return { - raised: false as const, - reason: "No employer change on the facts for this contact.", - }; - } - - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { - firstName: true, - lastName: true, - ownerId: true, - companyId: true, - }, - }); - if (!contact) return { raised: false as const, reason: "No such contact." }; - - const name = [contact.firstName, contact.lastName] - .filter(Boolean) - .join(" "); - - await writeTimelineNote( - contactId, - `${name} has moved to ${change.to}`, - [ - `${name} appears to have left ${change.from} for ${change.to}.`, - change.sourceUrl ?? "", - "", - "Worth a conversation either way: a champion in a new seat is the", - "warmest introduction there is, and their replacement at the old", - "account is a relationship nobody owns yet.", - ] - .filter(Boolean) - .join("\n"), - { source: "job-change", from: change.from, to: change.to }, - ); - - if (moveToCompanyId) { - await db.contact.update({ - where: { id: contactId }, - data: { companyId: moveToCompanyId }, - }); - } - - return { - raised: true as const, - from: change.from, - to: change.to, - moved: Boolean(moveToCompanyId), - ownerNotified: contact.ownerId !== null, - }; + return raiseJobChange({ contactId, moveToCompanyId }); }, }); diff --git a/apps/agent/agent/tools/resolve_linkedin_profile.ts b/apps/agent/agent/tools/resolve_linkedin_profile.ts index b1432146d..8fadb7d4d 100644 --- a/apps/agent/agent/tools/resolve_linkedin_profile.ts +++ b/apps/agent/agent/tools/resolve_linkedin_profile.ts @@ -1,33 +1,30 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; -import { enabled, unavailable } from "../lib/capabilities"; +import { CONTEXT_DEV, enabled, unavailable } from "../lib/capabilities"; import { spend } from "../lib/focus"; -import { searchTerms } from "../lib/names"; -import { findProfileUrls } from "../lib/perplexity"; +import { findLinkedInCandidates } from "../lib/linkedin-candidates"; export default defineTool({ description: - "Find candidate LinkedIn profile slugs for a work email address. Returns CANDIDATES ONLY — you must verify each with get_linkedin_profile before believing any of them.", + "Find candidate LinkedIn profile slugs for a work email via Context web search (site:linkedin.com/in). Returns CANDIDATES ONLY — never a match. Verify each with get_linkedin_profile. LinkDAPI is not used here; it enriches a known slug only.", inputSchema: z.object({ email: z.string().describe("The contact's work email address."), companyName: z.string().describe("The company the CRM has them at."), }), async execute({ email, companyName }) { - if (!(await enabled("PERPLEXITY_API_KEY"))) { - return { candidateSlugs: [], ...unavailable("PERPLEXITY_API_KEY") }; + if (!(await enabled(CONTEXT_DEV))) { + return { candidateSlugs: [], ...unavailable(CONTEXT_DEV) }; } const charge = spend(); if (!charge.ok) return { candidateSlugs: [], note: charge.reason }; - const local = email.split("@")[0] ?? ""; - const terms = searchTerms(local); - const slugs = await findProfileUrls(terms, companyName); + const found = await findLinkedInCandidates(email, companyName); return { - searchedFor: terms, - candidateSlugs: slugs.slice(0, 5), - note: "Unverified. Each slug must be checked with get_linkedin_profile.", + searchedFor: found.searchedFor, + candidateSlugs: found.candidateSlugs, + note: found.note, }; }, }); diff --git a/apps/agent/agent/tools/schedule_recheck.ts b/apps/agent/agent/tools/schedule_recheck.ts index fa9188913..94216f669 100644 --- a/apps/agent/agent/tools/schedule_recheck.ts +++ b/apps/agent/agent/tools/schedule_recheck.ts @@ -1,12 +1,10 @@ import { PRIORITY } from "@crm/db/agent-tasks"; import { defineTool } from "eve/tools"; import { z } from "zod"; +import { daysFromNow, RECHECK } from "../lib/recheck-config"; import { assertResearchPurpose } from "../lib/session-purpose"; import { scheduleTask } from "../lib/tasks"; -const MIN_DAYS = 1; -const MAX_DAYS = 730; - export default defineTool({ description: "Decide when this contact is worth looking at again, and say why. Use a short interval for people whose job change would move a live deal, a long one for quiet records, and skip it entirely for addresses nobody will ever sell to.", @@ -15,10 +13,10 @@ export default defineTool({ days: z .number() .int() - .min(MIN_DAYS) - .max(MAX_DAYS) + .min(RECHECK.minDays) + .max(RECHECK.maxDays) .describe( - "14 for a champion on an open deal; 90 for a named contact with no deal; 365 when two attempts have found nothing.", + `${RECHECK.championDays} for a champion on an open deal; ${RECHECK.namedDays} for a named contact with no deal; ${RECHECK.baselineDays} for a steady job-change feed; ${RECHECK.emptyDays} when two attempts have found nothing.`, ), reason: z .string() @@ -31,12 +29,12 @@ export default defineTool({ .int() .min(1) .max(20) - .default(4) + .default(RECHECK.defaultBudget) .describe("Vendor calls the next run may spend."), }), async execute({ contactId, days, reason, budget }, ctx) { assertResearchPurpose(ctx); - const dueAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + const dueAt = daysFromNow(days); await scheduleTask({ contactId, diff --git a/apps/agent/agent/tools/write_brief.ts b/apps/agent/agent/tools/write_brief.ts index 190d1f04b..902c22f27 100644 --- a/apps/agent/agent/tools/write_brief.ts +++ b/apps/agent/agent/tools/write_brief.ts @@ -10,7 +10,7 @@ const MAX_NARRATIVE = 400; export default defineTool({ description: - "Write the Background panel on a contact: a short narrative plus the structured lines under it. Replaces the previous one. Every claim must come from something you read.", + "Write the Background panel on a contact: a short narrative plus the structured lines under it. Replaces the previous one. Every claim must come from something you read. Refuses when identity is not trustworthy — identify them first.", inputSchema: z.object({ contactId: z.string(), narrative: z diff --git a/apps/agent/agent/tools/write_deal_intelligence.ts b/apps/agent/agent/tools/write_deal_intelligence.ts new file mode 100644 index 000000000..b08d7d9eb --- /dev/null +++ b/apps/agent/agent/tools/write_deal_intelligence.ts @@ -0,0 +1,45 @@ +import { DEAL_SCORE } from "@crm/db/deal-score"; +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { writeDealIntelligence } from "../lib/deal-intelligence"; +import { assertResearchPurpose } from "../lib/session-purpose"; + +export default defineTool({ + description: + "Write the deal score (0–100), one-paragraph rationale, and AI forecast context on a deal. Replaces the previous AI score and forecast. Does not overwrite forecastContextManual. Base every claim on the deal timeline you already read.", + inputSchema: z.object({ + dealId: z.string(), + score: z + .number() + .int() + .min(DEAL_SCORE.min) + .max(DEAL_SCORE.max) + .describe( + "Health 0–100 from stage age, activity cadence, contact coverage, and note content.", + ), + summary: z + .string() + .min(40) + .max(DEAL_SCORE.summaryMax) + .describe( + "One paragraph on why this score. Present tense. No fluff. Cite stage age, activity, contacts, and notes.", + ), + forecastContext: z + .string() + .min(40) + .max(DEAL_SCORE.forecastMax) + .describe( + "Rolling summary of the deal timeline for forecast. What moved, what blocks, what is next.", + ), + }), + async execute(input, ctx) { + assertResearchPurpose(ctx); + + return writeDealIntelligence({ + dealId: input.dealId, + score: input.score, + summary: input.summary, + forecastContext: input.forecastContext, + }); + }, +}); diff --git a/apps/agent/test/approval.spec.ts b/apps/agent/test/approval.spec.ts new file mode 100644 index 000000000..7161bc8f8 --- /dev/null +++ b/apps/agent/test/approval.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "bun:test"; +import { APP_AUTH } from "../agent/lib/app-auth"; +import { + isAutomated, + sensitiveWhen, + sensitiveWrite, +} from "../agent/lib/approval"; + +const appSession = { + auth: { + current: { + authenticator: APP_AUTH.authenticator, + principalId: APP_AUTH.principalId, + principalType: APP_AUTH.principalType, + }, + }, +}; + +const humanSession = { + auth: { + current: { + authenticator: "crm-app", + principalId: "user_123", + principalType: "user", + }, + }, +}; + +function decide( + policy: ReturnType, + session: typeof appSession | typeof humanSession, + toolInput?: Record, +) { + return policy({ + session, + toolInput, + toolName: "record_job_change", + callId: "call_1", + approvedTools: new Set(), + } as never); +} + +describe("isAutomated", () => { + it("matches the app principal on all three fields", () => { + expect(isAutomated(appSession)).toBe(true); + expect(isAutomated(humanSession)).toBe(false); + }); +}); + +describe("sensitiveWrite", () => { + const policy = sensitiveWrite("Do the safe path instead."); + + it("denies unattended runs", async () => { + expect(await decide(policy, appSession)).toEqual({ + type: "denied", + reason: "Not something to do unattended. Do the safe path instead.", + }); + }); + + it("asks a person on a human session", async () => { + expect(await decide(policy, humanSession)).toBe("user-approval"); + }); +}); + +describe("sensitiveWhen", () => { + const policy = sensitiveWhen<{ moveToCompanyId?: string }>( + (input) => Boolean(input?.moveToCompanyId), + "Raise without moveToCompanyId.", + ); + + it("lets automated runs raise a job change without re-parenting", async () => { + expect(await decide(policy, appSession, {})).toBe("not-applicable"); + expect(await decide(policy, appSession, { contactId: "c1" })).toBe( + "not-applicable", + ); + }); + + it("denies automated re-parenting of a contact company", async () => { + expect( + await decide(policy, appSession, { moveToCompanyId: "co_1" }), + ).toEqual({ + type: "denied", + reason: "Not something to do unattended. Raise without moveToCompanyId.", + }); + }); + + it("asks a person only when re-parenting", async () => { + expect(await decide(policy, humanSession, {})).toBe("not-applicable"); + expect( + await decide(policy, humanSession, { moveToCompanyId: "co_1" }), + ).toBe("user-approval"); + }); +}); diff --git a/apps/agent/test/brief-identity.spec.ts b/apps/agent/test/brief-identity.spec.ts new file mode 100644 index 000000000..4dbbfc874 --- /dev/null +++ b/apps/agent/test/brief-identity.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "bun:test"; +import { + type ContactIdentitySnapshot, + contactIdentityIsTrustworthy, + evidenceProvesIdentity, + refuseBriefReason, +} from "../agent/lib/brief-identity"; +import type { Evidence } from "../agent/lib/evidence"; + +const of = (...kinds: Evidence["kind"][]): Evidence[] => + kinds.map((kind) => ({ kind, detail: `saw ${kind}` })); + +const placeholder: ContactIdentitySnapshot = { + email: "pmarchetti@fernhill.com", + firstName: "Pmarchetti", + lastName: null, + linkedinUrl: null, + hasAppliedName: false, +}; + +const known: ContactIdentitySnapshot = { + email: "paula@fernhill.com", + firstName: "Paula", + lastName: "Marchetti", + linkedinUrl: "https://www.linkedin.com/in/paulamarchetti", + hasAppliedName: true, +}; + +describe("evidenceProvesIdentity", () => { + it("accepts employer-and-name and thread reply", () => { + expect(evidenceProvesIdentity(of("linkedin.employer-and-name"))).toBe(true); + expect(evidenceProvesIdentity(of("crm.thread-reply"))).toBe(true); + }); + + it("rejects employer-only and meeting attendance alone", () => { + expect(evidenceProvesIdentity(of("employer-only"))).toBe(false); + expect(evidenceProvesIdentity(of("crm.meeting-attendance"))).toBe(false); + expect(evidenceProvesIdentity(of("search.cites-profile"))).toBe(false); + }); +}); + +describe("contactIdentityIsTrustworthy", () => { + it("accepts a LinkedIn URL or applied name", () => { + expect(contactIdentityIsTrustworthy(known)).toBe(true); + expect( + contactIdentityIsTrustworthy({ + ...placeholder, + hasAppliedName: true, + }), + ).toBe(true); + }); + + it("rejects a derived placeholder name with no sources", () => { + expect(contactIdentityIsTrustworthy(placeholder)).toBe(false); + }); + + it("accepts a human full name without LinkedIn", () => { + expect( + contactIdentityIsTrustworthy({ + email: "x@example.com", + firstName: "Lewis", + lastName: "Carhart", + linkedinUrl: null, + hasAppliedName: false, + }), + ).toBe(true); + }); +}); + +describe("refuseBriefReason", () => { + it("refuses garbage identity even when evidence scores as probable", () => { + const reason = refuseBriefReason({ + contact: placeholder, + evidence: of("crm.meeting-attendance"), + }); + expect(reason).toMatch(/Identity is not trustworthy/); + }); + + it("refuses employer-only on an unknown contact", () => { + const reason = refuseBriefReason({ + contact: placeholder, + evidence: of("employer-only"), + }); + expect(reason).toMatch(/Identity is not trustworthy/); + }); + + it("allows a brief when identity evidence is primary", () => { + expect( + refuseBriefReason({ + contact: placeholder, + evidence: of("linkedin.employer-and-name"), + }), + ).toBeNull(); + }); + + it("allows a brief on a known contact with primary evidence", () => { + expect( + refuseBriefReason({ + contact: known, + evidence: of("linkedin.employer-and-name", "crm.signature-block"), + }), + ).toBeNull(); + }); + + it("refuses weak content evidence even when the contact is known", () => { + const reason = refuseBriefReason({ + contact: known, + evidence: of("web.cited-claim"), + }); + expect(reason).toMatch(/sourced well enough/); + }); +}); diff --git a/apps/agent/test/capabilities.spec.ts b/apps/agent/test/capabilities.spec.ts index 7b4ee8b95..641073f4a 100644 --- a/apps/agent/test/capabilities.spec.ts +++ b/apps/agent/test/capabilities.spec.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { CONTEXT_DEV, capabilitiesFrom, + enableChecklistMarkdown, enabled, + FULL_AGENTIC_CHECKLIST, + FULL_AGENTIC_ENV_VARS, markdownFor, unavailable, } from "../agent/lib/capabilities"; @@ -11,6 +14,7 @@ const KEYS = [ "RAPIDAPI_KEY", "PERPLEXITY_API_KEY", "BLOB_READ_WRITE_TOKEN", + "AGENT_BRIDGE_SECRET", ] as const; const saved: Record = {}; @@ -33,6 +37,7 @@ describe("capabilities", () => { it("reports everything off on a bare install", async () => { expect(capabilitiesFrom(null).every((c) => !c.enabled)).toBe(true); expect(await enabled("RAPIDAPI_KEY")).toBe(false); + expect(await enabled("AGENT_BRIDGE_SECRET")).toBe(false); }); it("turns one on without turning on the others", async () => { @@ -40,11 +45,14 @@ describe("capabilities", () => { expect(await enabled("PERPLEXITY_API_KEY")).toBe(true); expect(await enabled("RAPIDAPI_KEY")).toBe(false); + expect(await enabled("AGENT_BRIDGE_SECRET")).toBe(false); }); it("treats blank and whitespace as unset", async () => { process.env.RAPIDAPI_KEY = " "; + process.env.AGENT_BRIDGE_SECRET = "\t "; expect(await enabled("RAPIDAPI_KEY")).toBe(false); + expect(await enabled("AGENT_BRIDGE_SECRET")).toBe(false); }); it("is read live, so a late-configured process is not stuck off", async () => { @@ -58,6 +66,16 @@ describe("capabilities", () => { expect(await enabled("SOMETHING_ELSE")).toBe(false); delete process.env.SOMETHING_ELSE; }); + + it("reports AGENT_BRIDGE_SECRET when set", async () => { + process.env.AGENT_BRIDGE_SECRET = "bridge-secret"; + const bridge = capabilitiesFrom(null).find( + (c) => c.id === "AGENT_BRIDGE_SECRET", + ); + expect(bridge?.enabled).toBe(true); + expect(bridge?.from).toBe("AGENT_BRIDGE_SECRET"); + expect(await enabled("AGENT_BRIDGE_SECRET")).toBe(true); + }); }); describe("the Context key is a setting, never a variable", () => { @@ -112,6 +130,7 @@ describe("the capability briefing", () => { expect(markdown).toContain("LinkedIn"); expect(markdown).toContain("Not configured here"); expect(markdown).toContain("Web research"); + expect(markdown).toContain("Agent panel"); }); it("counts a stored Context key as configured", () => { @@ -133,3 +152,36 @@ describe("the capability briefing", () => { ); }); }); + +describe("full agentic enable checklist", () => { + it("names every production env var and the Context setting", () => { + expect(FULL_AGENTIC_ENV_VARS).toEqual([ + "RAPIDAPI_KEY", + "PERPLEXITY_API_KEY", + "BLOB_READ_WRITE_TOKEN", + "AGENT_BRIDGE_SECRET", + ]); + expect( + FULL_AGENTIC_CHECKLIST.find((item) => item.kind === "setting")?.source, + ).toBe("Settings → General"); + }); + + it("lists env names only, never secret values", () => { + process.env.RAPIDAPI_KEY = "super-secret-value"; + const text = enableChecklistMarkdown(capabilitiesFrom(null)); + + expect(text).toContain("`RAPIDAPI_KEY`"); + expect(text).toContain("`PERPLEXITY_API_KEY`"); + expect(text).toContain("`BLOB_READ_WRITE_TOKEN`"); + expect(text).toContain("`AGENT_BRIDGE_SECRET`"); + expect(text).toContain("Settings → General"); + expect(text).not.toContain("super-secret-value"); + expect(text).toContain("[x] `RAPIDAPI_KEY`"); + expect(text).toContain("[ ] `PERPLEXITY_API_KEY`"); + }); + + it("marks Context when a key is stored", () => { + const text = enableChecklistMarkdown(capabilitiesFrom("ctx")); + expect(text).toContain("[x] Context key at `Settings → General`"); + }); +}); diff --git a/apps/agent/test/deal-intelligence.integration.spec.ts b/apps/agent/test/deal-intelligence.integration.spec.ts new file mode 100644 index 000000000..583f9e106 --- /dev/null +++ b/apps/agent/test/deal-intelligence.integration.spec.ts @@ -0,0 +1,127 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DealStage, db } from "@crm/db"; +import { effectiveForecastContext } from "@crm/db/deal-score"; +import { writeDealIntelligence } from "../agent/lib/deal-intelligence"; +import { brief } from "../agent/lib/dispatch"; + +const suffix = process.env.TEST_RUN_ID ?? crypto.randomUUID().slice(0, 8); +const domain = `deal-intel-${suffix}.test`; +const ownerId = `deal-intel-owner-${suffix}`; + +let companyId = ""; +let dealId = ""; + +async function clean() { + if (dealId) { + await db.deal.deleteMany({ where: { id: dealId } }); + } + await db.company.deleteMany({ where: { domain } }); + await db.user.deleteMany({ where: { id: ownerId } }); +} + +beforeAll(async () => { + await clean(); + + await db.user.create({ + data: { + id: ownerId, + name: "Intel Owner", + email: `${ownerId}@example.test`, + emailVerified: true, + }, + }); + + const company = await db.company.create({ + data: { name: `Intel Co ${suffix}`, domain }, + select: { id: true }, + }); + companyId = company.id; + + const deal = await db.deal.create({ + data: { + name: `Intel Deal ${suffix}`, + companyId, + ownerId, + stage: DealStage.DEMO_BOOKED, + forecastContextManual: "Rep override stays put.", + }, + select: { id: true }, + }); + dealId = deal.id; +}); + +afterAll(async () => { + await clean(); +}); + +const summary = + "Stage is young with a champion on the account, but economic buyer coverage is thin and activity has slowed."; +const forecast = + "Demo booked last week; next step is a technical review. Risk is no CFO contact and two quiet weeks on email."; + +describe("writeDealIntelligence", () => { + it("writes score fields without overwriting manual forecast", async () => { + const result = await writeDealIntelligence({ + dealId, + score: 67, + summary, + forecastContext: forecast, + }); + + expect(result.written).toBe(true); + if (!result.written) return; + + const deal = await db.deal.findUnique({ + where: { id: dealId }, + select: { + dealScore: true, + dealScoreSummary: true, + dealScoredAt: true, + forecastContext: true, + forecastContextManual: true, + }, + }); + + expect(deal?.dealScore).toBe(67); + expect(deal?.dealScoreSummary).toBe(summary); + expect(deal?.forecastContext).toBe(forecast); + expect(deal?.forecastContextManual).toBe("Rep override stays put."); + expect(deal?.dealScoredAt).not.toBeNull(); + expect( + effectiveForecastContext( + deal?.forecastContext, + deal?.forecastContextManual, + ), + ).toBe("Rep override stays put."); + }); + + it("rejects empty summary", async () => { + const result = await writeDealIntelligence({ + dealId, + score: 50, + summary: " ", + forecastContext: forecast, + }); + expect(result.written).toBe(false); + }); + + it("briefs the research lane for deal-score tasks", () => { + const text = brief({ + id: "t1", + contactId: null, + companyId: null, + dealId, + kind: "deal-score", + reason: "Stage changed", + payload: null, + budget: 6, + attempts: 1, + priority: 80, + dueAt: new Date(), + }); + + expect(text).toContain("write_deal_intelligence"); + expect(text).toContain("0–100"); + expect(text).toContain("forecastContextManual"); + }); +}); diff --git a/apps/agent/test/evidence.spec.ts b/apps/agent/test/evidence.spec.ts index 2c215accf..550454f25 100644 --- a/apps/agent/test/evidence.spec.ts +++ b/apps/agent/test/evidence.spec.ts @@ -16,6 +16,16 @@ describe("scoreEvidence", () => { expect(scored.hasPrimary).toBe(true); }); + it("writes when LinkedIn employer and name both match", () => { + const scored = scoreEvidence(of("linkedin.employer-and-name")); + expect(scored.band).toBe("VERIFIED"); + expect(scored.hasPrimary).toBe(true); + }); + + it("keeps employer-only below the floor so strangers are not stored", () => { + expect(scoreEvidence(of("employer-only")).band).toBeNull(); + }); + it("treats our own mailbox as primary evidence", () => { expect(scoreEvidence(of("crm.thread-reply")).band).toBe("VERIFIED"); }); diff --git a/apps/agent/test/facts.integration.spec.ts b/apps/agent/test/facts.integration.spec.ts index 4c9fc42e8..0b69dedee 100644 --- a/apps/agent/test/facts.integration.spec.ts +++ b/apps/agent/test/facts.integration.spec.ts @@ -291,5 +291,21 @@ describe("writeBrief", () => { }); expect(result.written).toBe(false); + expect(result.reason).toMatch( + /Identity is not trustworthy|sourced well enough/, + ); + }); + + it("refuses meeting-attendance alone as garbage identity", async () => { + const result = await writeBrief({ + contactId, + narrative: + "Subject is someone we met once and should prepare carefully for.", + sections: {}, + evidence: [seen("crm.meeting-attendance")], + }); + + expect(result.written).toBe(false); + expect(result.reason).toMatch(/Identity is not trustworthy/); }); }); diff --git a/apps/agent/test/identity-verdict.spec.ts b/apps/agent/test/identity-verdict.spec.ts new file mode 100644 index 000000000..c198e2abe --- /dev/null +++ b/apps/agent/test/identity-verdict.spec.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "bun:test"; +import { scoreEvidence } from "../agent/lib/evidence"; +import { fillsBlank } from "../agent/lib/facts"; +import { + identityChecks, + identityEvidence, + identityNextStep, +} from "../agent/lib/identity-verdict"; + +const tomi = { + firstName: "Tomi", + lastName: "Okonkwo", + fullName: "Tomi Okonkwo", + profileUrl: "https://www.linkedin.com/in/tomi-okonkwo", + positions: [{ name: "Northwind Bank", url: null }], +}; + +const stranger = { + firstName: "Antonio", + lastName: "Fontana", + fullName: "Antonio Fontana", + profileUrl: "https://www.linkedin.com/in/antonio-fontana", + positions: [{ name: "Northwind Bank", url: null }], +}; + +const wrongCompany = { + firstName: "Tomi", + lastName: "Okonkwo", + fullName: "Tomi Okonkwo", + profileUrl: "https://www.linkedin.com/in/tomi-elsewhere", + positions: [{ name: "Brightwater Group", url: null }], +}; + +describe("identityChecks", () => { + it("requires employer and name together", () => { + expect( + identityChecks( + tomi, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ), + ).toEqual({ + employerMatches: true, + nameMatches: true, + isSamePerson: true, + }); + + expect( + identityChecks( + stranger, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ), + ).toEqual({ + employerMatches: true, + nameMatches: false, + isSamePerson: false, + }); + + expect( + identityChecks( + wrongCompany, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ), + ).toEqual({ + employerMatches: false, + nameMatches: true, + isSamePerson: false, + }); + }); +}); + +describe("identityEvidence", () => { + it("prices a full match as linkedin.employer-and-name at VERIFIED", () => { + const checks = identityChecks( + tomi, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ); + const evidence = identityEvidence( + tomi, + checks, + "tokonkwo@northwind.com", + "Northwind", + ); + + expect(evidence).toHaveLength(1); + expect(evidence[0]?.kind).toBe("linkedin.employer-and-name"); + expect(scoreEvidence(evidence).band).toBe("VERIFIED"); + expect(identityNextStep(checks)).toContain("identify_contact"); + }); + + it("prices employer-only below the keep floor so a miss stays a miss", () => { + const checks = identityChecks( + stranger, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ); + const evidence = identityEvidence( + stranger, + checks, + "tokonkwo@northwind.com", + "Northwind", + ); + + expect(evidence[0]?.kind).toBe("employer-only"); + expect(scoreEvidence(evidence).band).toBeNull(); + expect(identityNextStep(checks)).toContain("miss stays a miss"); + }); + + it("returns no identity evidence when only the name fits", () => { + const checks = identityChecks( + wrongCompany, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ); + const evidence = identityEvidence( + wrongCompany, + checks, + "tokonkwo@northwind.com", + "Northwind", + ); + + expect(evidence).toEqual([]); + expect(scoreEvidence(evidence).band).toBeNull(); + expect(identityNextStep(checks)).toContain("Stop"); + }); +}); + +describe("fillsBlank for identity names", () => { + it("treats a derived email placeholder as blank for the agent", () => { + expect( + fillsBlank({ + field: "name", + contact: { + email: "tokonkwo@northwind.com", + firstName: "Tokonkwo", + lastName: null, + }, + hasAgentFact: false, + }), + ).toBe(true); + }); + + it("does not treat a human-typed name as blank", () => { + expect( + fillsBlank({ + field: "name", + contact: { + email: "tokonkwo@northwind.com", + firstName: "Tomi", + lastName: "Okonkwo", + }, + hasAgentFact: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/agent/test/images.spec.ts b/apps/agent/test/images.spec.ts index aef9e8e05..cb51c17a8 100644 --- a/apps/agent/test/images.spec.ts +++ b/apps/agent/test/images.spec.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { profilePhotoUrl, slugFromProfileUrl } from "../agent/lib/linkdapi"; +import { runPortrait, storePortrait } from "../agent/lib/portrait"; import { findPortrait } from "../agent/lib/portrait-sources"; const OVERVIEW = { @@ -128,4 +129,76 @@ describe("the portrait source chain", () => { expect(result.found).toBe(false); if (!result.found) expect(result.reason).toBe("Out of budget."); }); + + it("does not charge for a free GitHub headshot", async () => { + let spent = 0; + const result = await findPortrait( + { ...NOBODY, githubUrl: "https://github.com/pmarchetti" }, + (units = 1) => { + spent += units; + return { ok: true }; + }, + ); + + expect(result.found).toBe(true); + expect(spent).toBe(0); + }); + + it("never treats a bare name as a place to look", async () => { + const result = await findPortrait( + { ...NOBODY, name: "Paula Marchetti", companyName: "Acme" }, + free, + ); + + expect(result.found).toBe(false); + if (!result.found) expect(result.tried).toEqual([]); + }); +}); + +describe("portrait soft-fail without BLOB_READ_WRITE_TOKEN", () => { + const saved = process.env.BLOB_READ_WRITE_TOKEN; + + beforeEach(() => { + delete process.env.BLOB_READ_WRITE_TOKEN; + }); + + afterEach(() => { + if (saved === undefined) delete process.env.BLOB_READ_WRITE_TOKEN; + else process.env.BLOB_READ_WRITE_TOKEN = saved; + }); + + it("storePortrait refuses to keep an origin URL and does not throw", async () => { + const result = await storePortrait({ + contactId: "c-no-blob", + sourceUrl: "https://media.licdn.com/dms/image/x.jpg", + verified: true, + }); + + expect(result.stored).toBe(false); + expect(result.imageUrl).toBe(null); + expect(result.reason).toContain("BLOB_READ_WRITE_TOKEN"); + expect(result.reason?.toLowerCase()).toContain("retrying will not help"); + }); + + it("runPortrait stops before looking when there is nowhere to store a copy", async () => { + const result = await runPortrait({ + contactId: "c-no-blob", + spend: () => ({ ok: true }), + }); + + expect(result.stored).toBe(false); + expect(result.imageUrl).toBe(null); + expect(result.reason).toContain("BLOB_READ_WRITE_TOKEN"); + }); + + it("storePortrait still refuses an unverified face without needing storage", async () => { + const result = await storePortrait({ + contactId: "c-no-blob", + sourceUrl: "https://media.licdn.com/dms/image/x.jpg", + verified: false, + }); + + expect(result.stored).toBe(false); + expect(result.reason).toContain("not established"); + }); }); diff --git a/apps/agent/test/job-change.integration.spec.ts b/apps/agent/test/job-change.integration.spec.ts new file mode 100644 index 000000000..9dd3c225a --- /dev/null +++ b/apps/agent/test/job-change.integration.spec.ts @@ -0,0 +1,256 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { ActivityType, db } from "@crm/db"; +import type { Evidence } from "../agent/lib/evidence"; +import { lastEmployerChange, recordFact } from "../agent/lib/facts"; +import { raiseJobChange } from "../agent/lib/job-change"; + +const suffix = process.env.TEST_RUN_ID ?? "job-change-spec"; +const email = `job.change.${suffix}@example.test`; +const ownerEmail = `owner.job.${suffix}@example.test`; + +let contactId: string; +let ownerId: string; +let companyId: string; +let otherCompanyId: string; + +const seen = (kind: Evidence["kind"], detail = "observed"): Evidence => ({ + kind, + detail, +}); + +async function cleanup() { + await db.activity.deleteMany({ + where: { + OR: [{ contact: { email } }, { createdBy: { email: ownerEmail } }], + }, + }); + await db.contactFact.deleteMany({ where: { contact: { email } } }); + await db.contact.deleteMany({ where: { email } }); + await db.company.deleteMany({ + where: { domain: { in: [`old-${suffix}.test`, `new-${suffix}.test`] } }, + }); + await db.user.deleteMany({ where: { email: ownerEmail } }); +} + +beforeAll(async () => { + await cleanup(); + + const owner = await db.user.create({ + data: { + id: `owner-${suffix}`, + name: "Owner Rep", + email: ownerEmail, + emailVerified: true, + }, + select: { id: true }, + }); + ownerId = owner.id; + + const oldCompany = await db.company.create({ + data: { name: `Old Co ${suffix}`, domain: `old-${suffix}.test` }, + select: { id: true }, + }); + companyId = oldCompany.id; + + const newCompany = await db.company.create({ + data: { name: `New Co ${suffix}`, domain: `new-${suffix}.test` }, + select: { id: true }, + }); + otherCompanyId = newCompany.id; + + const contact = await db.contact.create({ + data: { + firstName: "Champion", + lastName: "Mover", + email, + companyId, + ownerId, + }, + select: { id: true }, + }); + contactId = contact.id; + + await recordFact({ + contactId, + field: "employer", + value: "Fleetio", + evidence: [seen("linkedin.employer-and-name")], + method: "linkedin.profile", + sourceUrl: "https://www.linkedin.com/in/champion-mover", + }); + await recordFact({ + contactId, + field: "employer", + value: "Comp AI", + evidence: [seen("linkedin.employer-and-name")], + method: "linkedin.profile", + sourceUrl: "https://www.linkedin.com/in/champion-mover", + }); +}); + +afterAll(cleanup); + +describe("lastEmployerChange", () => { + it("reads the superseding employer pair", async () => { + const change = await lastEmployerChange(contactId); + expect(change).toMatchObject({ + from: "Fleetio", + to: "Comp AI", + sourceUrl: "https://www.linkedin.com/in/champion-mover", + }); + }); +}); + +describe("raiseJobChange", () => { + it("writes a note and an owner TASK without moving the company", async () => { + const result = await raiseJobChange({ contactId }); + + expect(result).toMatchObject({ + raised: true, + from: "Fleetio", + to: "Comp AI", + moved: false, + ownerNotified: true, + }); + if (!result.raised) return; + + expect(result.noteId).toBeTruthy(); + expect(result.taskId).toBeTruthy(); + + const note = await db.activity.findUnique({ + where: { id: result.noteId! }, + select: { + type: true, + subject: true, + createdById: true, + contactId: true, + meta: true, + }, + }); + expect(note).toMatchObject({ + type: ActivityType.NOTE, + subject: "Champion Mover has moved to Comp AI", + createdById: ownerId, + contactId, + }); + expect(note?.meta).toMatchObject({ + source: "job-change", + from: "Fleetio", + to: "Comp AI", + }); + + const task = await db.activity.findUnique({ + where: { id: result.taskId! }, + select: { + type: true, + subject: true, + createdById: true, + dueAt: true, + completedAt: true, + meta: true, + }, + }); + expect(task).toMatchObject({ + type: ActivityType.TASK, + subject: "Champion Mover has moved to Comp AI", + createdById: ownerId, + completedAt: null, + }); + expect(task?.dueAt).toBeInstanceOf(Date); + expect(task?.meta).toMatchObject({ source: "job-change" }); + + const contact = await db.contact.findUnique({ + where: { id: contactId }, + select: { companyId: true }, + }); + expect(contact?.companyId).toBe(companyId); + }); + + it("re-parents only when moveToCompanyId is given after approval", async () => { + const result = await raiseJobChange({ + contactId, + moveToCompanyId: otherCompanyId, + }); + + expect(result).toMatchObject({ + raised: true, + moved: true, + ownerNotified: true, + }); + + const contact = await db.contact.findUnique({ + where: { id: contactId }, + select: { companyId: true }, + }); + expect(contact?.companyId).toBe(otherCompanyId); + }); + + it("refuses when there is no employer supersession", async () => { + const lonely = await db.contact.create({ + data: { + firstName: "Still", + lastName: "Here", + email: `still.here.${suffix}@example.test`, + ownerId, + }, + select: { id: true }, + }); + + await recordFact({ + contactId: lonely.id, + field: "employer", + value: "Same Co", + evidence: [seen("linkedin.employer-and-name")], + method: "linkedin.profile", + }); + + const result = await raiseJobChange({ contactId: lonely.id }); + expect(result).toEqual({ + raised: false, + reason: "No employer change on the facts for this contact.", + }); + + await db.contactFact.deleteMany({ where: { contactId: lonely.id } }); + await db.contact.delete({ where: { id: lonely.id } }); + }); + + it("reports ownerNotified false when the contact has no owner", async () => { + const orphan = await db.contact.create({ + data: { + firstName: "No", + lastName: "Owner", + email: `no.owner.${suffix}@example.test`, + }, + select: { id: true }, + }); + + await recordFact({ + contactId: orphan.id, + field: "employer", + value: "Alpha", + evidence: [seen("linkedin.employer-and-name")], + method: "linkedin.profile", + }); + await recordFact({ + contactId: orphan.id, + field: "employer", + value: "Beta", + evidence: [seen("linkedin.employer-and-name")], + method: "linkedin.profile", + }); + + const result = await raiseJobChange({ contactId: orphan.id }); + expect(result).toMatchObject({ + raised: true, + ownerNotified: false, + taskId: null, + }); + if (result.raised) { + expect(result.noteId).toBeTruthy(); + } + + await db.activity.deleteMany({ where: { contactId: orphan.id } }); + await db.contactFact.deleteMany({ where: { contactId: orphan.id } }); + await db.contact.delete({ where: { id: orphan.id } }); + }); +}); diff --git a/apps/agent/test/keyless-brand.integration.spec.ts b/apps/agent/test/keyless-brand.integration.spec.ts index 7b2902b35..ebbbc2408 100644 --- a/apps/agent/test/keyless-brand.integration.spec.ts +++ b/apps/agent/test/keyless-brand.integration.spec.ts @@ -1,5 +1,7 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { db, EnrichmentStatus } from "@crm/db"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; +import { db, EnrichmentStatus, type Prisma } from "@crm/db"; +import { SETTINGS_ID } from "@crm/db/settings"; +import { runBrand } from "../agent/lib/brand"; import { settle } from "../agent/lib/enrichment"; /** @@ -8,16 +10,30 @@ import { settle } from "../agent/lib/enrichment"; * the *record*: the sign-in sweep re-queues companies whose enrichment never * succeeded, and it decides that on `enrichmentStatus` being PENDING or FAILED. * - * `runBrand` settles SKIPPED before anything marks the row RUNNING, and - * `settle` only writes over a RUNNING row — so the row stays PENDING and the - * sweep picks it up once a key exists. That is load bearing and entirely - * implicit, which is why it is pinned here: a `settle` that wrote - * unconditionally would strand every company added before the key, with - * nothing to say so. + * `runBrand` returns without writing enrichment status when the key is missing, + * so the row stays PENDING. `settle` on the enrichment path only writes over a + * RUNNING row. Either path must leave PENDING companies re-queueable once a key + * exists. A settle that wrote SKIPPED unconditionally would strand every + * company added before the key, with nothing to say so. */ const created: string[] = []; const tasks: string[] = []; +let savedSettings: Prisma.AppSettingUncheckedCreateInput | null = null; + +beforeAll(async () => { + savedSettings = await db.appSetting.findUnique({ + where: { id: SETTINGS_ID }, + }); +}); + +afterAll(async () => { + await db.appSetting.deleteMany({ where: { id: SETTINGS_ID } }); + if (savedSettings) { + await db.appSetting.create({ data: savedSettings }); + } +}); + afterEach(async () => { if (tasks.length > 0) { await db.agentTask.deleteMany({ where: { id: { in: tasks.splice(0) } } }); @@ -131,4 +147,15 @@ describe("a brand task with no key", () => { expect(await statusOf(id)).toBe(EnrichmentStatus.COMPLETE); }); + + it("runBrand leaves PENDING when Context is not configured", async () => { + await db.appSetting.deleteMany({ where: { id: SETTINGS_ID } }); + + const id = await company(EnrichmentStatus.PENDING); + const result = await runBrand({ companyId: id }); + + expect(result.enriched).toBe(false); + expect(result.reason).toContain("not configured"); + expect(await statusOf(id)).toBe(EnrichmentStatus.PENDING); + }); }); diff --git a/apps/agent/test/lanes.integration.spec.ts b/apps/agent/test/lanes.integration.spec.ts index a2cde1ce9..8ad309329 100644 --- a/apps/agent/test/lanes.integration.spec.ts +++ b/apps/agent/test/lanes.integration.spec.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { db } from "@crm/db"; -import { DIRECT_KINDS, isDirectKind, PRIORITY } from "@crm/db/agent-tasks"; +import { + DIRECT_KINDS, + isDirectKind, + PORTRAIT_STAND_DOWN_MS, + PRIORITY, +} from "@crm/db/agent-tasks"; import { claimDue } from "../agent/lib/tasks"; const REASON = "lane-test"; @@ -101,15 +106,28 @@ describe("kind vocabulary", () => { it("agrees on which kinds skip the model", () => { expect(isDirectKind("brand")).toBe(true); expect(isDirectKind("portrait")).toBe(true); + expect(isDirectKind("stalled-deal")).toBe(true); expect(isDirectKind("company-profile")).toBe(false); expect(isDirectKind("identify")).toBe(false); expect(isDirectKind("workspace-profile")).toBe(false); }); it("puts what a rep sees first above what they have to click for", () => { + expect(PRIORITY.brand).toBe(900); + expect(PRIORITY.portrait).toBe(800); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.portrait); expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.requested); expect(PRIORITY.portrait).toBeGreaterThan(PRIORITY.requested); expect(PRIORITY.requested).toBeGreaterThan(PRIORITY.companyProfile); expect(PRIORITY.companyProfile).toBeGreaterThan(PRIORITY.recheck); }); + + it("puts portrait on the visible direct lane", () => { + expect(DIRECT_KINDS).toContain("portrait"); + expect(isDirectKind("portrait")).toBe(true); + }); + + it("stands a finished portrait down for thirty days before looking again", () => { + expect(PORTRAIT_STAND_DOWN_MS).toBe(30 * 24 * 60 * 60 * 1000); + }); }); diff --git a/apps/agent/test/lifecycle-advance.spec.ts b/apps/agent/test/lifecycle-advance.spec.ts new file mode 100644 index 000000000..34c478d23 --- /dev/null +++ b/apps/agent/test/lifecycle-advance.spec.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_ACTION_TYPES } from "../agent/lib/agent-actions"; +import { parseAgentManifest } from "../agent/lib/agent-manifest"; +import { + ADVANCE_LIFECYCLE_ROLE, + ADVANCE_RECOMMEND_ONLY_ACTION_TYPES, + ADVANCE_SPECIALIST_NAME, + advanceSpecialistDraft, + advanceSpecialistManifest, + assertAdvanceRecommendOnlyActions, + isAdvanceRecommendOnlyActionType, +} from "../agent/lib/lifecycle-advance"; +import { + builderDraftToolInput, + draftInputFromTool, +} from "../agent/subagents/agent_builder/lib/draft-input"; + +const FIXED_NOW = "2026-08-12T12:00:00.000Z"; + +describe("lifecycle advance specialist", () => { + it("builds a workspace draft tagged as advance with event and schedule triggers", () => { + const draft = advanceSpecialistDraft({ now: FIXED_NOW }); + + expect(draft.name).toBe(ADVANCE_SPECIALIST_NAME); + expect(draft.lifecycleRole).toBe(ADVANCE_LIFECYCLE_ROLE); + expect(draft.recordScope).toBe("WORKSPACE"); + expect(draft.triggers.map((trigger) => trigger.type).sort()).toEqual([ + "EVENT", + "EVENT", + "MANUAL", + "SCHEDULE", + ]); + expect( + draft.triggers + .map((trigger) => trigger.event) + .filter(Boolean) + .sort(), + ).toEqual(["deal.opened", "deal.stage.changed"]); + const schedule = draft.triggers.find( + (trigger) => trigger.type === "SCHEDULE", + ); + expect(schedule?.intervalMinutes).toBe(24 * 60); + expect(schedule?.nextRunAt).toBe(FIXED_NOW); + expect(draft.actions.map((action) => action.type).sort()).toEqual( + [...ADVANCE_RECOMMEND_ONLY_ACTION_TYPES].sort(), + ); + }); + + it("builds a selected manual draft when records are supplied", () => { + const draft = advanceSpecialistDraft({ + recordScope: "SELECTED", + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(draft.recordScope).toBe("SELECTED"); + expect(draft.triggers).toHaveLength(1); + expect(draft.triggers[0]?.type).toBe("MANUAL"); + expect(draft.resources).toEqual([ + { kind: "deal", id: "d1", label: "Acme expansion" }, + ]); + }); + + it("rejects selected drafts without records and workspace drafts with records", () => { + expect(() => + advanceSpecialistDraft({ recordScope: "SELECTED", resources: [] }), + ).toThrow("Selected Advance draft"); + expect(() => + advanceSpecialistDraft({ + recordScope: "WORKSPACE", + resources: [{ kind: "deal", id: "d1", label: "Acme" }], + }), + ).toThrow("Workspace Advance draft"); + }); + + it("parses the advance template as a valid runner manifest", () => { + const manifest = advanceSpecialistManifest({ now: FIXED_NOW }); + + expect(manifest.lifecycleRole).toBe("advance"); + expect(manifest.dataScope.mode).toBe("WORKSPACE"); + expect( + manifest.actions.every((action) => + isAdvanceRecommendOnlyActionType(action.type), + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.RUN_SUMMARY, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + ), + ).toBe(false); + expect( + manifest.triggers.some( + (trigger) => + trigger.type === "EVENT" && trigger.config.event === "deal.opened", + ), + ).toBe(true); + expect( + manifest.triggers.some( + (trigger) => + trigger.type === "EVENT" && + trigger.config.event === "deal.stage.changed", + ), + ).toBe(true); + expect( + manifest.triggers.some((trigger) => trigger.type === "SCHEDULE"), + ).toBe(true); + }); + + it("keeps selected advance manifests recommend-only", () => { + const manifest = advanceSpecialistManifest({ + recordScope: "SELECTED", + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(manifest.dataScope.mode).toBe("SELECTED"); + assertAdvanceRecommendOnlyActions(manifest.actions); + }); + + it("forbids non-recommend action types on advance", () => { + expect(() => + assertAdvanceRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.SLACK_MESSAGE_POST }, + ]), + ).toThrow("forbids action type"); + expect(() => + assertAdvanceRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE }, + { type: AGENT_ACTION_TYPES.RUN_SUMMARY }, + ]), + ).not.toThrow(); + }); + + it("round-trips lifecycleRole through builder draft input", () => { + const draft = advanceSpecialistDraft({ now: FIXED_NOW }); + const parsed = builderDraftToolInput.parse({ + name: draft.name, + description: draft.description, + instructions: draft.instructions, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => { + if (trigger.type === "EVENT") { + return { + type: "EVENT" as const, + name: trigger.name, + summary: trigger.summary, + event: trigger.event as "deal.opened" | "deal.stage.changed", + }; + } + if (trigger.type === "SCHEDULE") { + return { + type: "SCHEDULE" as const, + name: trigger.name, + summary: trigger.summary, + intervalMinutes: trigger.intervalMinutes as number, + nextRunAt: trigger.nextRunAt as string, + }; + } + return { + type: "MANUAL" as const, + name: trigger.name, + summary: trigger.summary, + }; + }), + recordScope: draft.recordScope, + resources: [], + integrations: [], + actions: draft.actions, + }); + + expect(draftInputFromTool(parsed).lifecycleRole).toBe("advance"); + expect( + parseAgentManifest({ + description: parsed.description, + lifecycleRole: parsed.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "EVENT" + ? { event: trigger.event } + : trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? FIXED_NOW, + } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: "Workspace open deals for advance recommendations", + resources: [], + }, + actions: draft.actions, + }).lifecycleRole, + ).toBe("advance"); + }); + + it("instructions forbid unattended stage mutation and outreach", () => { + const draft = advanceSpecialistDraft(); + expect(draft.instructions).toContain("Never change deal stage"); + expect(draft.instructions).toContain("Never send email"); + expect(draft.description).toContain("Recommend only"); + }); +}); diff --git a/apps/agent/test/lifecycle-close.spec.ts b/apps/agent/test/lifecycle-close.spec.ts new file mode 100644 index 000000000..e875149a9 --- /dev/null +++ b/apps/agent/test/lifecycle-close.spec.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_ACTION_TYPES } from "../agent/lib/agent-actions"; +import { parseAgentManifest } from "../agent/lib/agent-manifest"; +import { + assertCloseRecommendOnlyActions, + CLOSE_LIFECYCLE_ROLE, + CLOSE_RECOMMEND_ONLY_ACTION_TYPES, + CLOSE_SPECIALIST_NAME, + closeSpecialistDraft, + closeSpecialistManifest, + isCloseRecommendOnlyActionType, +} from "../agent/lib/lifecycle-close"; +import { + builderDraftToolInput, + draftInputFromTool, +} from "../agent/subagents/agent_builder/lib/draft-input"; + +describe("lifecycle close specialist", () => { + it("builds a selected manual draft tagged as close by default", () => { + const draft = closeSpecialistDraft({ + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(draft.name).toBe(CLOSE_SPECIALIST_NAME); + expect(draft.lifecycleRole).toBe(CLOSE_LIFECYCLE_ROLE); + expect(draft.recordScope).toBe("SELECTED"); + expect(draft.triggers).toHaveLength(1); + expect(draft.triggers[0]?.type).toBe("MANUAL"); + expect(draft.resources).toEqual([ + { kind: "deal", id: "d1", label: "Acme expansion" }, + ]); + expect(draft.actions.map((action) => action.type).sort()).toEqual( + [...CLOSE_RECOMMEND_ONLY_ACTION_TYPES].sort(), + ); + }); + + it("builds a workspace draft with deal.closed when requested", () => { + const draft = closeSpecialistDraft({ recordScope: "WORKSPACE" }); + + expect(draft.recordScope).toBe("WORKSPACE"); + expect(draft.triggers.map((trigger) => trigger.type).sort()).toEqual([ + "EVENT", + "MANUAL", + ]); + expect( + draft.triggers.map((trigger) => trigger.event).filter(Boolean), + ).toEqual(["deal.closed"]); + expect(draft.resources).toEqual([]); + }); + + it("rejects selected drafts without records and workspace drafts with records", () => { + expect(() => + closeSpecialistDraft({ recordScope: "SELECTED", resources: [] }), + ).toThrow("Selected Close draft"); + expect(() => + closeSpecialistDraft({ + recordScope: "WORKSPACE", + resources: [{ kind: "deal", id: "d1", label: "Acme" }], + }), + ).toThrow("Workspace Close draft"); + }); + + it("parses the close template as a valid runner manifest", () => { + const manifest = closeSpecialistManifest({ + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(manifest.lifecycleRole).toBe("close"); + expect(manifest.dataScope.mode).toBe("SELECTED"); + expect( + manifest.actions.every((action) => + isCloseRecommendOnlyActionType(action.type), + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.RUN_SUMMARY, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + ), + ).toBe(false); + }); + + it("keeps workspace close manifests recommend-only with deal.closed", () => { + const manifest = closeSpecialistManifest({ recordScope: "WORKSPACE" }); + + expect(manifest.dataScope.mode).toBe("WORKSPACE"); + expect(manifest.triggers.some((trigger) => trigger.type === "EVENT")).toBe( + true, + ); + assertCloseRecommendOnlyActions(manifest.actions); + }); + + it("forbids non-recommend action types on close", () => { + expect(() => + assertCloseRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.SLACK_MESSAGE_POST }, + ]), + ).toThrow("forbids action type"); + expect(() => + assertCloseRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE }, + { type: AGENT_ACTION_TYPES.RUN_SUMMARY }, + ]), + ).not.toThrow(); + }); + + it("round-trips lifecycleRole through builder draft input", () => { + const draft = closeSpecialistDraft({ + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + const parsed = builderDraftToolInput.parse({ + name: draft.name, + description: draft.description, + instructions: draft.instructions, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: "MANUAL" as const, + name: trigger.name, + summary: trigger.summary, + })), + recordScope: draft.recordScope, + resources: draft.resources, + integrations: [], + actions: draft.actions, + }); + + expect(draftInputFromTool(parsed).lifecycleRole).toBe("close"); + expect( + parseAgentManifest({ + description: parsed.description, + lifecycleRole: parsed.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: {}, + })), + dataScope: { + mode: draft.recordScope, + summary: "Selected CRM records for manual close handoff", + resources: draft.resources, + }, + actions: draft.actions, + }).lifecycleRole, + ).toBe("close"); + }); + + it("leaves generic team agents without a lifecycle role", () => { + const manifest = parseAgentManifest({ + description: "Generic alert", + triggers: [ + { + type: "MANUAL", + name: "Run", + summary: "On demand", + config: {}, + }, + ], + dataScope: { + mode: "WORKSPACE", + summary: "Workspace", + resources: [], + }, + actions: [ + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize", + }, + ], + }); + + expect(manifest.lifecycleRole).toBeUndefined(); + }); +}); diff --git a/apps/agent/test/lifecycle-engage.spec.ts b/apps/agent/test/lifecycle-engage.spec.ts new file mode 100644 index 000000000..37bee79df --- /dev/null +++ b/apps/agent/test/lifecycle-engage.spec.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_ACTION_TYPES } from "../agent/lib/agent-actions"; +import { parseAgentManifest } from "../agent/lib/agent-manifest"; +import { + assertEngageRecommendOnlyActions, + ENGAGE_LIFECYCLE_ROLE, + ENGAGE_RECOMMEND_ONLY_ACTION_TYPES, + ENGAGE_SPECIALIST_NAME, + engageSpecialistDraft, + engageSpecialistManifest, + isEngageRecommendOnlyActionType, +} from "../agent/lib/lifecycle-engage"; +import { + builderDraftToolInput, + draftInputFromTool, +} from "../agent/subagents/agent_builder/lib/draft-input"; + +describe("lifecycle engage specialist", () => { + it("builds a selected draft tagged as engage by default", () => { + const draft = engageSpecialistDraft({ + resources: [{ kind: "contact", id: "c1", label: "Ada Lovelace" }], + }); + + expect(draft.name).toBe(ENGAGE_SPECIALIST_NAME); + expect(draft.lifecycleRole).toBe(ENGAGE_LIFECYCLE_ROLE); + expect(draft.recordScope).toBe("SELECTED"); + expect(draft.triggers).toHaveLength(1); + expect(draft.triggers[0]?.type).toBe("MANUAL"); + expect(draft.actions.map((action) => action.type).sort()).toEqual( + [...ENGAGE_RECOMMEND_ONLY_ACTION_TYPES].sort(), + ); + }); + + it("builds a workspace draft with deal lifecycle events", () => { + const draft = engageSpecialistDraft({ recordScope: "WORKSPACE" }); + + expect(draft.recordScope).toBe("WORKSPACE"); + expect(draft.triggers.map((trigger) => trigger.type).sort()).toEqual([ + "EVENT", + "EVENT", + "MANUAL", + ]); + expect( + draft.triggers + .map((trigger) => trigger.event) + .filter(Boolean) + .sort(), + ).toEqual(["deal.opened", "deal.stage.changed"]); + }); + + it("rejects selected drafts without records and workspace drafts with records", () => { + expect(() => + engageSpecialistDraft({ recordScope: "SELECTED", resources: [] }), + ).toThrow("Selected Engage draft"); + expect(() => + engageSpecialistDraft({ + recordScope: "WORKSPACE", + resources: [{ kind: "company", id: "co1", label: "Acme" }], + }), + ).toThrow("Workspace Engage draft"); + }); + + it("parses the engage template as a valid runner manifest", () => { + const manifest = engageSpecialistManifest({ + resources: [{ kind: "contact", id: "c1", label: "Ada Lovelace" }], + }); + + expect(manifest.lifecycleRole).toBe("engage"); + expect(manifest.dataScope.mode).toBe("SELECTED"); + expect( + manifest.actions.every((action) => + isEngageRecommendOnlyActionType(action.type), + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.RUN_SUMMARY, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + ), + ).toBe(false); + }); + + it("keeps workspace engage manifests recommend-only", () => { + const manifest = engageSpecialistManifest({ recordScope: "WORKSPACE" }); + + expect(manifest.dataScope.mode).toBe("WORKSPACE"); + assertEngageRecommendOnlyActions(manifest.actions); + }); + + it("forbids non-recommend action types on engage", () => { + expect(() => + assertEngageRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.SLACK_MESSAGE_POST }, + ]), + ).toThrow("forbids action type"); + expect(() => + assertEngageRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE }, + { type: AGENT_ACTION_TYPES.RUN_SUMMARY }, + ]), + ).not.toThrow(); + }); + + it("round-trips lifecycleRole through builder draft input", () => { + const draft = engageSpecialistDraft({ + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + const parsed = builderDraftToolInput.parse({ + name: draft.name, + description: draft.description, + instructions: draft.instructions, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: "MANUAL" as const, + name: trigger.name, + summary: trigger.summary, + })), + recordScope: draft.recordScope, + resources: draft.resources + .filter((resource) => resource.kind !== "integration") + .map((resource) => ({ + kind: resource.kind as "company" | "contact" | "deal", + id: resource.id as string, + label: resource.label, + })), + integrations: [], + actions: draft.actions, + }); + + expect(draftInputFromTool(parsed).lifecycleRole).toBe("engage"); + expect( + parseAgentManifest({ + description: parsed.description, + lifecycleRole: parsed.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: {}, + })), + dataScope: { + mode: draft.recordScope, + summary: "Selected CRM records for manual outreach recommendations", + resources: draft.resources, + }, + actions: draft.actions, + }).lifecycleRole, + ).toBe("engage"); + }); + + it("leaves generic team agents without a lifecycle role", () => { + const manifest = parseAgentManifest({ + description: "Generic alert", + triggers: [ + { + type: "MANUAL", + name: "Run", + summary: "On demand", + config: {}, + }, + ], + dataScope: { + mode: "WORKSPACE", + summary: "Workspace", + resources: [], + }, + actions: [ + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize", + }, + ], + }); + + expect(manifest.lifecycleRole).toBeUndefined(); + }); +}); diff --git a/apps/agent/test/lifecycle-qualify.spec.ts b/apps/agent/test/lifecycle-qualify.spec.ts new file mode 100644 index 000000000..999ade1fa --- /dev/null +++ b/apps/agent/test/lifecycle-qualify.spec.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_ACTION_TYPES } from "../agent/lib/agent-actions"; +import { parseAgentManifest } from "../agent/lib/agent-manifest"; +import { + assertQualifyRecommendOnlyActions, + isRecommendOnlyActionType, + QUALIFY_LIFECYCLE_ROLE, + QUALIFY_RECOMMEND_ONLY_ACTION_TYPES, + QUALIFY_SPECIALIST_NAME, + qualifySpecialistDraft, + qualifySpecialistManifest, +} from "../agent/lib/lifecycle-qualify"; +import { + builderDraftToolInput, + draftInputFromTool, +} from "../agent/subagents/agent_builder/lib/draft-input"; + +describe("lifecycle qualify specialist", () => { + it("builds a workspace intake draft tagged as qualify", () => { + const draft = qualifySpecialistDraft(); + + expect(draft.name).toBe(QUALIFY_SPECIALIST_NAME); + expect(draft.lifecycleRole).toBe(QUALIFY_LIFECYCLE_ROLE); + expect(draft.recordScope).toBe("WORKSPACE"); + expect(draft.triggers.map((trigger) => trigger.type).sort()).toEqual([ + "EVENT", + "EVENT", + "MANUAL", + ]); + expect( + draft.triggers.map((trigger) => trigger.event).filter(Boolean), + ).toEqual(["contact.created", "company.created"]); + expect(draft.actions.map((action) => action.type).sort()).toEqual( + [...QUALIFY_RECOMMEND_ONLY_ACTION_TYPES].sort(), + ); + }); + + it("builds a selected manual draft when records are supplied", () => { + const draft = qualifySpecialistDraft({ + recordScope: "SELECTED", + resources: [{ kind: "contact", id: "c1", label: "Ada Lovelace" }], + }); + + expect(draft.recordScope).toBe("SELECTED"); + expect(draft.triggers).toHaveLength(1); + expect(draft.triggers[0]?.type).toBe("MANUAL"); + expect(draft.resources).toEqual([ + { kind: "contact", id: "c1", label: "Ada Lovelace" }, + ]); + }); + + it("rejects selected drafts without records and workspace drafts with records", () => { + expect(() => + qualifySpecialistDraft({ recordScope: "SELECTED", resources: [] }), + ).toThrow("Selected Qualify draft"); + expect(() => + qualifySpecialistDraft({ + recordScope: "WORKSPACE", + resources: [{ kind: "company", id: "co1", label: "Acme" }], + }), + ).toThrow("Workspace Qualify draft"); + }); + + it("parses the qualify template as a valid runner manifest", () => { + const manifest = qualifySpecialistManifest(); + + expect(manifest.lifecycleRole).toBe("qualify"); + expect(manifest.dataScope.mode).toBe("WORKSPACE"); + expect( + manifest.actions.every((action) => + isRecommendOnlyActionType(action.type), + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.RUN_SUMMARY, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + ), + ).toBe(false); + }); + + it("keeps selected qualify manifests recommend-only", () => { + const manifest = qualifySpecialistManifest({ + recordScope: "SELECTED", + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(manifest.dataScope.mode).toBe("SELECTED"); + assertQualifyRecommendOnlyActions(manifest.actions); + }); + + it("forbids non-recommend action types on qualify", () => { + expect(() => + assertQualifyRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.SLACK_MESSAGE_POST }, + ]), + ).toThrow("forbids action type"); + expect(() => + assertQualifyRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE }, + { type: AGENT_ACTION_TYPES.RUN_SUMMARY }, + ]), + ).not.toThrow(); + }); + + it("round-trips lifecycleRole through builder draft input", () => { + const draft = qualifySpecialistDraft(); + const parsed = builderDraftToolInput.parse({ + name: draft.name, + description: draft.description, + instructions: draft.instructions, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => + trigger.type === "EVENT" + ? { + type: "EVENT" as const, + name: trigger.name, + summary: trigger.summary, + event: trigger.event as "contact.created" | "company.created", + } + : { + type: "MANUAL" as const, + name: trigger.name, + summary: trigger.summary, + }, + ), + recordScope: draft.recordScope, + resources: [], + integrations: [], + actions: draft.actions, + }); + + expect(draftInputFromTool(parsed).lifecycleRole).toBe("qualify"); + expect( + parseAgentManifest({ + description: parsed.description, + lifecycleRole: parsed.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: trigger.type === "EVENT" ? { event: trigger.event } : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: "Workspace CRM records for intake qualification", + resources: [], + }, + actions: draft.actions, + }).lifecycleRole, + ).toBe("qualify"); + }); + + it("leaves generic team agents without a lifecycle role", () => { + const manifest = parseAgentManifest({ + description: "Generic alert", + triggers: [ + { + type: "MANUAL", + name: "Run", + summary: "On demand", + config: {}, + }, + ], + dataScope: { + mode: "WORKSPACE", + summary: "Workspace", + resources: [], + }, + actions: [ + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize", + }, + ], + }); + + expect(manifest.lifecycleRole).toBeUndefined(); + }); +}); diff --git a/apps/agent/test/linkedin-candidates.spec.ts b/apps/agent/test/linkedin-candidates.spec.ts new file mode 100644 index 000000000..4532192f3 --- /dev/null +++ b/apps/agent/test/linkedin-candidates.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test"; +import { + linkedInSearchQuery, + linkedInSlugsFromResults, + linkedInSlugsFromText, +} from "../agent/lib/linkedin-candidates"; + +describe("linkedInSlugsFromText", () => { + it("extracts profile handles from mixed text", () => { + expect( + linkedInSlugsFromText( + "See https://www.linkedin.com/in/tomi-okonkwo and linkedin.com/in/PaulaMarchetti/", + ), + ).toEqual(["tomi-okonkwo", "paulamarchetti"]); + }); + + it("deduplicates the same handle", () => { + expect( + linkedInSlugsFromText( + "https://linkedin.com/in/jane-doe https://www.linkedin.com/in/jane-doe/", + ), + ).toEqual(["jane-doe"]); + }); + + it("ignores non-profile linkedin urls", () => { + expect( + linkedInSlugsFromText("https://www.linkedin.com/company/northwind"), + ).toEqual([]); + }); +}); + +describe("linkedInSlugsFromResults", () => { + it("reads the result url and body text", () => { + const slugs = linkedInSlugsFromResults([ + { + url: "https://www.linkedin.com/in/tokonkwo", + title: "Tomi Okonkwo", + description: null, + markdown: null, + }, + { + url: "https://example.com/blog", + title: "Also mentioned", + description: "Profile at linkedin.com/in/other-person", + markdown: null, + }, + ]); + + expect(slugs).toEqual(["tokonkwo", "other-person"]); + }); +}); + +describe("linkedInSearchQuery", () => { + it("scopes search to linkedin profiles with the company", () => { + expect(linkedInSearchQuery("okonkwo", "Northwind")).toBe( + "site:linkedin.com/in okonkwo Northwind", + ); + expect(linkedInSearchQuery("marchetti", "Fernhill Bank")).toBe( + 'site:linkedin.com/in marchetti "Fernhill Bank"', + ); + }); +}); diff --git a/apps/agent/test/meeting-prep-dispatch.spec.ts b/apps/agent/test/meeting-prep-dispatch.spec.ts new file mode 100644 index 000000000..4b1f1f78f --- /dev/null +++ b/apps/agent/test/meeting-prep-dispatch.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { PRIORITY } from "@crm/db/agent-tasks"; +import { brief } from "../agent/lib/dispatch"; +import type { LeasedTask } from "../agent/lib/tasks"; + +function task(kind: string, attempts = 1): LeasedTask { + return { + id: "task_test", + kind, + reason: "Meeting on Thu", + priority: PRIORITY.meeting, + budget: 10, + attempts, + contactId: "contact_test", + companyId: null, + dealId: null, + payload: null, + dueAt: new Date(), + }; +} + +describe("meeting-prep dispatch brief", () => { + it("tells the session to identify before writing", () => { + const text = brief(task("meeting-prep")); + expect(text).toMatch(/identity/i); + expect(text).toMatch(/write_brief|brief/i); + expect(text).toMatch(/meeting-prep/); + }); + + it("keeps meeting priority at 200", () => { + expect(PRIORITY.meeting).toBe(200); + }); +}); diff --git a/apps/agent/test/recheck-config.spec.ts b/apps/agent/test/recheck-config.spec.ts new file mode 100644 index 000000000..8ae75b708 --- /dev/null +++ b/apps/agent/test/recheck-config.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import { daysFromNow, JOB_CHANGE, RECHECK } from "../agent/lib/recheck-config"; + +describe("RECHECK", () => { + it("keeps champion shorter than named, named shorter than empty", () => { + expect(RECHECK.championDays).toBe(14); + expect(RECHECK.namedDays).toBe(90); + expect(RECHECK.baselineDays).toBe(30); + expect(RECHECK.emptyDays).toBe(365); + expect(RECHECK.championDays).toBeLessThan(RECHECK.namedDays); + expect(RECHECK.namedDays).toBeLessThan(RECHECK.emptyDays); + expect(RECHECK.championDays).toBeLessThan(RECHECK.baselineDays); + }); + + it("bounds schedule_recheck days", () => { + expect(RECHECK.minDays).toBe(1); + expect(RECHECK.maxDays).toBe(730); + expect(RECHECK.defaultBudget).toBe(4); + }); +}); + +describe("JOB_CHANGE", () => { + it("gives the owner a near due date", () => { + expect(JOB_CHANGE.ownerTaskDueDays).toBe(2); + }); +}); + +describe("daysFromNow", () => { + it("adds whole days from a fixed instant", () => { + const from = Date.UTC(2026, 0, 1, 12, 0, 0); + expect(daysFromNow(14, from).toISOString()).toBe( + "2026-01-15T12:00:00.000Z", + ); + }); +}); diff --git a/apps/agent/test/stalled-deal.integration.spec.ts b/apps/agent/test/stalled-deal.integration.spec.ts new file mode 100644 index 000000000..d0f31ac21 --- /dev/null +++ b/apps/agent/test/stalled-deal.integration.spec.ts @@ -0,0 +1,127 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { ActivityType, DealStage, db } from "@crm/db"; +import { PRIORITY } from "@crm/db/agent-tasks"; +import { STALLED_DEAL } from "@crm/db/stalled-deals"; +import { flagStalledDeal } from "../agent/lib/stalled-deal"; +import type { LeasedTask } from "../agent/lib/tasks"; + +const suffix = process.env.TEST_RUN_ID ?? crypto.randomUUID().slice(0, 8); +const domain = `flag-stalled-${suffix}.test`; +const ownerId = `flag-owner-${suffix}`; +const staleAt = new Date("2026-07-20T12:00:00.000Z"); + +let companyId = ""; +let dealId = ""; + +function task(over: Partial = {}): LeasedTask { + return { + id: `task-${suffix}`, + contactId: null, + companyId: null, + dealId, + kind: STALLED_DEAL.kind, + reason: "Stale Renewal has had no activity for 23 days.", + payload: null, + budget: 1, + attempts: 1, + priority: PRIORITY.stalledDeal, + dueAt: new Date(), + ...over, + }; +} + +async function clean() { + if (dealId) { + await db.activity.deleteMany({ where: { dealId } }); + await db.deal.deleteMany({ where: { id: dealId } }); + } + await db.company.deleteMany({ where: { domain } }); + await db.user.deleteMany({ where: { id: ownerId } }); +} + +beforeAll(async () => { + await clean(); + + await db.user.create({ + data: { + id: ownerId, + name: "Flag Owner", + email: `${ownerId}@example.test`, + emailVerified: true, + }, + }); + + const company = await db.company.create({ + data: { name: `Flag Co ${suffix}`, domain }, + select: { id: true }, + }); + companyId = company.id; + + const deal = await db.deal.create({ + data: { + name: `Stale Renewal ${suffix}`, + companyId, + ownerId, + stage: DealStage.CONTRACT_SENT, + createdAt: staleAt, + lastActivityAt: staleAt, + }, + select: { id: true }, + }); + dealId = deal.id; +}); + +afterAll(clean); + +describe("flagStalledDeal", () => { + it("creates one owner task and leaves lastActivityAt alone", async () => { + const before = await db.deal.findUniqueOrThrow({ + where: { id: dealId }, + select: { lastActivityAt: true }, + }); + + const first = await flagStalledDeal(task()); + expect(first).toBe("Raised an owner task for the stalled deal."); + + const activities = await db.activity.findMany({ + where: { + dealId, + type: ActivityType.TASK, + completedAt: null, + }, + select: { + subject: true, + createdById: true, + meta: true, + }, + }); + + expect(activities).toHaveLength(1); + expect(activities[0]?.createdById).toBe(ownerId); + expect(activities[0]?.subject).toContain("Re-engage:"); + expect(activities[0]?.meta).toMatchObject({ + source: STALLED_DEAL.source, + }); + + const after = await db.deal.findUniqueOrThrow({ + where: { id: dealId }, + select: { lastActivityAt: true }, + }); + expect(after.lastActivityAt?.getTime()).toBe( + before.lastActivityAt?.getTime(), + ); + + const second = await flagStalledDeal(task()); + expect(second).toBe("Owner already has an open stalled-deal task."); + + const open = await db.activity.count({ + where: { + dealId, + type: ActivityType.TASK, + completedAt: null, + meta: { path: ["source"], equals: STALLED_DEAL.source }, + }, + }); + expect(open).toBe(1); + }); +}); diff --git a/apps/api/src/agent/agent-definitions.service.ts b/apps/api/src/agent/agent-definitions.service.ts index 9d6521fd8..afdba82a3 100644 --- a/apps/api/src/agent/agent-definitions.service.ts +++ b/apps/api/src/agent/agent-definitions.service.ts @@ -1,6 +1,6 @@ import type { Db, Prisma } from "@crm/db"; import type { AgentDefinitionStatus } from "@crm/db/enums"; -import { schemas } from "@crm/validation"; +import { readLifecycleRole, schemas } from "@crm/validation"; import { BadRequestException, Injectable, @@ -43,7 +43,13 @@ export class AgentDefinitionsService { updatedAt: true, createdBy: { select: { id: true, name: true, image: true } }, currentVersion: { - select: { id: true, number: true, deployedAt: true }, + select: { id: true, number: true, deployedAt: true, manifest: true }, + }, + versions: { + where: { status: { in: ["DRAFT", "READY"] } }, + orderBy: { number: "desc" }, + take: 1, + select: { manifest: true }, }, triggers: { where: { enabled: true }, @@ -55,22 +61,29 @@ export class AgentDefinitionsService { }, }); - return rows.map((row) => ({ - ...row, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - currentVersion: row.currentVersion - ? { - ...row.currentVersion, - deployedAt: row.currentVersion.deployedAt?.toISOString() ?? null, - } - : null, - triggers: row.triggers.map((trigger) => ({ - ...trigger, - nextRunAt: trigger.nextRunAt?.toISOString() ?? null, - })), - runCount: row._count.runs, - })); + return rows.map((row) => { + const { versions, currentVersion, ...agent } = row; + const manifest = currentVersion?.manifest ?? versions[0]?.manifest; + const { manifest: _manifest, ...currentVersionPublic } = + currentVersion ?? {}; + return { + ...agent, + lifecycleRole: readLifecycleRole(manifest), + createdAt: agent.createdAt.toISOString(), + updatedAt: agent.updatedAt.toISOString(), + currentVersion: currentVersion + ? { + ...currentVersionPublic, + deployedAt: currentVersion.deployedAt?.toISOString() ?? null, + } + : null, + triggers: agent.triggers.map((trigger) => ({ + ...trigger, + nextRunAt: trigger.nextRunAt?.toISOString() ?? null, + })), + runCount: agent._count.runs, + }; + }); } async byId(id: string, userId: string) { @@ -825,6 +838,7 @@ function readCapabilities(manifest: unknown) { (issue) => `${issue.path.join(".") || "manifest"} ${issue.message}`, ) .join("; "), + lifecycleRole: readLifecycleRole(manifest), actions: [], dataScope: null, channel: null, @@ -838,6 +852,7 @@ function readCapabilities(manifest: unknown) { return { readable: true as const, problem: null, + lifecycleRole: parsed.data.lifecycleRole ?? null, actions: parsed.data.actions, dataScope: parsed.data.dataScope, channel: slack?.destination ?? null, diff --git a/apps/api/src/agent/agent-observability.service.ts b/apps/api/src/agent/agent-observability.service.ts new file mode 100644 index 000000000..d5729a27f --- /dev/null +++ b/apps/api/src/agent/agent-observability.service.ts @@ -0,0 +1,191 @@ +import type { Db } from "@crm/db"; +import { readLifecycleRole } from "@crm/validation"; +import { Injectable } from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { AgentAccessService } from "./agent-access.service"; +import { TEAM_AGENT_STATUSES } from "./agent-visibility"; + +const WINDOW_HOURS = 24; +const HOUR_MS = 60 * 60 * 1000; +const DEPENDENCY_UNAVAILABLE = "DEPENDENCY_UNAVAILABLE"; + +@Injectable() +export class AgentObservabilityService { + constructor( + @InjectDatabase() private readonly db: Db, + private readonly access: AgentAccessService, + ) {} + + async fleet(userId: string) { + await this.access.assertMember(userId); + + const since = new Date(Date.now() - WINDOW_HOURS * HOUR_MS); + + const [ + statusRows, + triggerRows, + openRuns, + runs, + actionTypeRows, + actionStatusRows, + agents, + ] = await Promise.all([ + this.db.agentRun.groupBy({ + by: ["status"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentRun.groupBy({ + by: ["triggerType"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentRun.groupBy({ + by: ["status"], + where: { + status: { in: ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"] }, + }, + _count: { _all: true }, + }), + this.db.agentRun.findMany({ + where: { createdAt: { gte: since } }, + select: { + status: true, + cancelRequestedAt: true, + errorCode: true, + inputTokens: true, + outputTokens: true, + costUsd: true, + sessionId: true, + version: { select: { manifest: true } }, + _count: { select: { actions: true, events: true } }, + }, + }), + this.db.agentAction.groupBy({ + by: ["type"], + where: { plannedAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentAction.groupBy({ + by: ["status"], + where: { plannedAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentDefinition.findMany({ + where: { status: { in: [...TEAM_AGENT_STATUSES] } }, + select: { + id: true, + name: true, + status: true, + currentVersion: { select: { manifest: true } }, + versions: { + where: { status: { in: ["DRAFT", "READY"] } }, + orderBy: { number: "desc" }, + take: 1, + select: { manifest: true }, + }, + _count: { select: { runs: true } }, + }, + }), + ]); + + const runsByRole: Record = {}; + let cancelled = 0; + let cancelAfterAction = 0; + let dependencyFailures = 0; + let inputTokens = 0; + let outputTokens = 0; + let costUsd = 0; + let sessionsWithTrace = 0; + let toolEvents = 0; + + for (const run of runs) { + const role = readLifecycleRole(run.version.manifest); + const roleKey = role ?? "none"; + runsByRole[roleKey] = (runsByRole[roleKey] ?? 0) + 1; + + if (run.status === "CANCELLED" || run.cancelRequestedAt) { + cancelled += 1; + if (run._count.actions > 0) cancelAfterAction += 1; + } + + if (run.errorCode === DEPENDENCY_UNAVAILABLE) { + dependencyFailures += 1; + } + + inputTokens += run.inputTokens ?? 0; + outputTokens += run.outputTokens ?? 0; + costUsd += Number(run.costUsd ?? 0); + if (run.sessionId) sessionsWithTrace += 1; + toolEvents += run._count.events; + } + + const agentsByRole: Record = {}; + for (const agent of agents) { + const manifest = + agent.currentVersion?.manifest ?? agent.versions[0]?.manifest; + const role = readLifecycleRole(manifest) ?? "none"; + agentsByRole[role] = (agentsByRole[role] ?? 0) + 1; + } + + return { + windowHours: WINDOW_HOURS, + since: since.toISOString(), + runsByStatus: counts( + statusRows.map((row) => [row.status, row._count._all]), + ), + runsByTrigger: counts( + triggerRows.map((row) => [row.triggerType, row._count._all]), + ), + runsByLifecycleRole: runsByRole, + openRunsByStatus: counts( + openRuns.map((row) => [row.status, row._count._all]), + ), + actionsByType: counts( + actionTypeRows.map((row) => [row.type, row._count._all]), + ), + actionsByStatus: counts( + actionStatusRows.map((row) => [row.status, row._count._all]), + ), + quality: { + cancelled, + cancelAfterAction, + dependencyFailures, + failed: runsByRoleCount(statusRows, "FAILED"), + succeeded: runsByRoleCount(statusRows, "SUCCEEDED"), + }, + consumption: { + inputTokens, + outputTokens, + costUsd: round(costUsd), + runs: runs.length, + sessionsWithTrace, + runEvents: toolEvents, + }, + agents: { + total: agents.length, + byStatus: counts(agents.map((agent) => [agent.status, 1])), + byLifecycleRole: agentsByRole, + }, + }; + } +} + +function counts(entries: Array<[string, number]>): Record { + const result: Record = {}; + for (const [key, count] of entries) { + result[key] = (result[key] ?? 0) + count; + } + return result; +} + +function runsByRoleCount( + rows: Array<{ status: string; _count: { _all: number } }>, + status: string, +): number { + return rows.find((row) => row.status === status)?._count._all ?? 0; +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index ea5226eeb..1aaaff500 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -240,12 +240,32 @@ export class AgentTriggerService { await this.enqueue({ contactId, kind: "meeting-prep", - reason: `Meeting on ${when.toDateString()} with someone we know nothing about`, + reason: `Meeting on ${when.toDateString()} — prepare a brief only if identity is trustworthy`, priority: PRIORITY.meeting, budget: 10, }); } + async stalledDeal(dealId: string, reason: string): Promise { + return this.enqueue({ + dealId, + kind: "stalled-deal", + reason, + priority: PRIORITY.stalledDeal, + budget: 1, + }); + } + + async dealScore(dealId: string, reason: string): Promise { + return this.enqueue({ + dealId, + kind: "deal-score", + reason, + priority: PRIORITY.dealScore, + budget: 6, + }); + } + builderConversationQueued(): void { this.pokeRoute("/internal/crm/builder-dispatch"); } @@ -364,6 +384,7 @@ export class AgentTriggerService { task: { contactId?: string; companyId?: string; + dealId?: string; kind: string; reason: string; priority: number; @@ -378,7 +399,7 @@ export class AgentTriggerService { const write = async (tx: Prisma.TransactionClient) => { await lockIdempotencyKey( tx, - `agent-task:${task.kind}:${task.contactId ?? ""}:${task.companyId ?? ""}:${task.subject?.value ?? ""}`, + `agent-task:${task.kind}:${task.contactId ?? ""}:${task.companyId ?? ""}:${task.dealId ?? ""}:${task.subject?.value ?? ""}`, ); const pending = await tx.agentTask.findFirst({ where: { @@ -386,6 +407,7 @@ export class AgentTriggerService { finishedAt: null, ...(task.contactId ? { contactId: task.contactId } : {}), ...(task.companyId ? { companyId: task.companyId } : {}), + ...(task.dealId ? { dealId: task.dealId } : {}), ...(task.subject ? { payload: { @@ -403,6 +425,7 @@ export class AgentTriggerService { data: { contactId: task.contactId ?? null, companyId: task.companyId ?? null, + dealId: task.dealId ?? null, kind: task.kind, reason: task.reason, priority: task.priority, @@ -424,6 +447,7 @@ export class AgentTriggerService { kind: task.kind, contactId: task.contactId, companyId: task.companyId, + dealId: task.dealId, }); if (!client) this.poke(); diff --git a/apps/api/src/agent/agent.module.ts b/apps/api/src/agent/agent.module.ts index cc700da83..de2a254a4 100644 --- a/apps/api/src/agent/agent.module.ts +++ b/apps/api/src/agent/agent.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { TrpcModule } from "../trpc/trpc.module"; import { AgentAccessService } from "./agent-access.service"; import { AgentDefinitionsService } from "./agent-definitions.service"; +import { AgentObservabilityService } from "./agent-observability.service"; import { AgentQueueService } from "./agent-queue.service"; import { AgentRunsService } from "./agent-runs.service"; import { AgentTriggerService } from "./agent-trigger.service"; @@ -14,6 +15,7 @@ import { ResearchKeyService } from "./research-key.service"; providers: [ AgentAccessService, AgentDefinitionsService, + AgentObservabilityService, AgentQueueService, AgentRunsService, AgentTriggerService, diff --git a/apps/api/src/agent/agents.router.ts b/apps/api/src/agent/agents.router.ts index 6e4f37859..0f4040a75 100644 --- a/apps/api/src/agent/agents.router.ts +++ b/apps/api/src/agent/agents.router.ts @@ -11,6 +11,7 @@ import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; import { AgentDefinitionsService } from "./agent-definitions.service"; +import { AgentObservabilityService } from "./agent-observability.service"; import { AgentRunsService } from "./agent-runs.service"; import { agentCancelRunInput, @@ -32,6 +33,8 @@ export class AgentsRouter { private readonly agents: AgentDefinitionsService, @Inject(AgentRunsService) private readonly runs: AgentRunsService, + @Inject(AgentObservabilityService) + private readonly fleetHealth: AgentObservabilityService, ) {} @Query() @@ -39,6 +42,11 @@ export class AgentsRouter { return this.agents.list(ctx.user.id); } + @Query() + async observability(@Ctx() ctx: AuthedTrpcContext) { + return this.fleetHealth.fleet(ctx.user.id); + } + @Mutation({ input: agentReviseInput }) async revise( @Ctx() ctx: AuthedTrpcContext, diff --git a/apps/api/src/backfill/backfill.service.ts b/apps/api/src/backfill/backfill.service.ts index 8f4b96071..c979ad34f 100644 --- a/apps/api/src/backfill/backfill.service.ts +++ b/apps/api/src/backfill/backfill.service.ts @@ -1,6 +1,7 @@ import { onSignedIn } from "@crm/auth"; import { type Db, EnrichmentStatus, type Prisma } from "@crm/db"; -import { PRIORITY } from "@crm/db/agent-tasks"; +import { PORTRAIT_STAND_DOWN_MS, PRIORITY } from "@crm/db/agent-tasks"; +import { blobEnabled } from "@crm/db/blob"; import { readWorkspaceIdentity } from "@crm/db/workspace"; import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Inject, Injectable, Logger, type OnModuleInit } from "@nestjs/common"; @@ -31,14 +32,7 @@ const AUTO_KEY = "backfill:auto"; const AUTO_EVERY_MS = 5 * 60_000; -/** - * How long a fruitless photo search stands the contact down for. - * - * Long, because the answer rarely changes: somebody with no LinkedIn account - * and no headshot on their employer's site is unlikely to acquire either this - * week, and the team-page read costs credits every time it is asked. - */ -const RECHECK_PHOTO_AFTER_MS = 30 * 24 * 60 * 60_000; +const RECHECK_PHOTO_AFTER_MS = PORTRAIT_STAND_DOWN_MS; const RECHECK_BRAND_AFTER_MS = 30 * 24 * 60 * 60_000; @@ -182,25 +176,30 @@ export class BackfillService implements OnModuleInit { } private async runContacts(): Promise { - const needsPhoto = await this.contactsNeedingPhoto(); - - const [photoTotal, photoRows] = await Promise.all([ - this.db.contact.count({ where: needsPhoto }), - this.db.contact.findMany({ - where: needsPhoto, - orderBy: { createdAt: "asc" }, - take: MAX_PER_RUN, - select: { id: true }, - }), - ]); - - const photos = await this.agent.backfill({ - kind: "portrait", - reason: "Backfill — somewhere to look for a picture, and no picture", - contactIds: photoRows.map((row) => row.id), - budget: 1, - priority: PRIORITY.portrait, - }); + const photosEnabled = blobEnabled(); + const needsPhoto = photosEnabled ? await this.contactsNeedingPhoto() : null; + + const [photoTotal, photoRows] = needsPhoto + ? await Promise.all([ + this.db.contact.count({ where: needsPhoto }), + this.db.contact.findMany({ + where: needsPhoto, + orderBy: { createdAt: "asc" }, + take: MAX_PER_RUN, + select: { id: true }, + }), + ]) + : [0, [] as { id: string }[]]; + + const photos = needsPhoto + ? await this.agent.backfill({ + kind: "portrait", + reason: "Backfill — somewhere to look for a picture, and no picture", + contactIds: photoRows.map((row) => row.id), + budget: 1, + priority: PRIORITY.portrait, + }) + : { queued: 0, alreadyQueued: 0 }; const headroom = MAX_PER_RUN - photoRows.length; diff --git a/apps/api/src/deals/deal-score.config.ts b/apps/api/src/deals/deal-score.config.ts new file mode 100644 index 000000000..b91d9c6f8 --- /dev/null +++ b/apps/api/src/deals/deal-score.config.ts @@ -0,0 +1,6 @@ +const HOUR_MS = 60 * 60 * 1_000; +const DAY_MS = 24 * HOUR_MS; + +export const DEAL_SCORE_SWEEP = { + auto: { everyMs: DAY_MS, cacheKey: "deal-score:auto" }, +} as const; diff --git a/apps/api/src/deals/deal-score.service.ts b/apps/api/src/deals/deal-score.service.ts new file mode 100644 index 000000000..1df983146 --- /dev/null +++ b/apps/api/src/deals/deal-score.service.ts @@ -0,0 +1,112 @@ +import { onSignedIn } from "@crm/auth"; +import type { Db } from "@crm/db"; +import { + DEAL_SCORE, + needsDealScore, + scoreRescoreCutoff, +} from "@crm/db/deal-score"; +import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Inject, Injectable, Logger, type OnModuleInit } from "@nestjs/common"; +import type { Cache } from "cache-manager"; +import { AgentTriggerService } from "../agent/agent-trigger.service"; +import { InjectDatabase } from "../database/database.constants"; +import { DEAL_SCORE_SWEEP } from "./deal-score.config"; + +export type DealScoreSweepResult = { + scanned: number; + queued: number; + alreadyQueued: number; +}; + +@Injectable() +export class DealScoreService implements OnModuleInit { + private readonly logger = new Logger(DealScoreService.name); + + constructor( + @InjectDatabase() private readonly db: Db, + private readonly agent: AgentTriggerService, + @Inject(CACHE_MANAGER) private readonly cache: Cache, + ) {} + + onModuleInit(): void { + onSignedIn(() => { + void this.auto(); + }); + } + + async auto(): Promise<{ started: boolean }> { + if (await this.cache.get(DEAL_SCORE_SWEEP.auto.cacheKey)) { + return { started: false }; + } + await this.cache.set( + DEAL_SCORE_SWEEP.auto.cacheKey, + true, + DEAL_SCORE_SWEEP.auto.everyMs, + ); + + void (async () => { + try { + const result = await this.sweep(); + if (result.queued > 0 || result.scanned > 0) { + this.logger.log({ + message: "Deal-score sweep finished", + ...result, + }); + } + } catch (error) { + this.logger.error( + { message: "Deal-score sweep failed" }, + error instanceof Error ? error.stack : String(error), + ); + } + })(); + + return { started: true }; + } + + async sweep(now = new Date()): Promise { + const cutoff = scoreRescoreCutoff(now); + + const deals = await this.db.deal.findMany({ + where: { + stage: { in: [...OPEN_DEAL_STAGES] }, + OR: [{ dealScoredAt: null }, { dealScoredAt: { lte: cutoff } }], + }, + orderBy: [ + { dealScoredAt: { sort: "asc", nulls: "first" } }, + { updatedAt: "asc" }, + { id: "asc" }, + ], + take: DEAL_SCORE.maxPerRun, + select: { + id: true, + name: true, + dealScoredAt: true, + }, + }); + + let queued = 0; + let alreadyQueued = 0; + + for (const deal of deals) { + if ( + !needsDealScore({ + dealScoredAt: deal.dealScoredAt, + now, + }) + ) { + continue; + } + + const reason = deal.dealScoredAt + ? `Nightly rescore for ${deal.name.trim() || "deal"}` + : `Score open deal ${deal.name.trim() || "deal"}`; + const created = await this.agent.dealScore(deal.id, reason); + if (created) queued += 1; + else alreadyQueued += 1; + } + + return { scanned: deals.length, queued, alreadyQueued }; + } +} diff --git a/apps/api/src/deals/deals.contracts.ts b/apps/api/src/deals/deals.contracts.ts index ef8ee4326..0170cb1d4 100644 --- a/apps/api/src/deals/deals.contracts.ts +++ b/apps/api/src/deals/deals.contracts.ts @@ -58,6 +58,7 @@ const dealUpdateInput = z.object({ amountCents, currency: currencyCode.optional(), expectedCloseDate: z.string().nullable().optional(), + forecastContextManual: z.string().max(2_000).nullable().optional(), fields: recordFieldValues.optional(), }); diff --git a/apps/api/src/deals/deals.module.ts b/apps/api/src/deals/deals.module.ts index 310aed015..88b562568 100644 --- a/apps/api/src/deals/deals.module.ts +++ b/apps/api/src/deals/deals.module.ts @@ -3,12 +3,14 @@ import { AgentModule } from "../agent/agent.module"; import { CurrencyModule } from "../currency/currency.module"; import { FieldsModule } from "../fields/fields.module"; import { TrpcModule } from "../trpc/trpc.module"; +import { DealScoreService } from "./deal-score.service"; import { DealsRouter } from "./deals.router"; import { DealsService } from "./deals.service"; +import { StalledDealsService } from "./stalled-deals.service"; @Module({ imports: [AgentModule, FieldsModule, TrpcModule, CurrencyModule], - providers: [DealsService, DealsRouter], - exports: [DealsService], + providers: [DealsService, DealsRouter, StalledDealsService, DealScoreService], + exports: [DealsService, StalledDealsService, DealScoreService], }) export class DealsModule {} diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts index 5bb09fecf..ddf8f5368 100644 --- a/apps/api/src/deals/deals.service.ts +++ b/apps/api/src/deals/deals.service.ts @@ -202,6 +202,11 @@ export class DealsService { expectedCloseDate: true, closedAt: true, closedReason: true, + dealScore: true, + dealScoreSummary: true, + dealScoredAt: true, + forecastContext: true, + forecastContextManual: true, createdAt: true, company: { select: { ...COMPANY_SELECT, industry: true } }, owner: { select: OWNER_SELECT }, @@ -229,6 +234,7 @@ export class DealsService { stageChangedAt: deal.stageChangedAt.toISOString(), expectedCloseDate: deal.expectedCloseDate?.toISOString() ?? null, closedAt: deal.closedAt?.toISOString() ?? null, + dealScoredAt: deal.dealScoredAt?.toISOString() ?? null, createdAt: deal.createdAt.toISOString(), contacts: contacts.map(({ role, contact }) => ({ ...contact, role })), }; @@ -312,6 +318,12 @@ export class DealsService { if (input.expectedCloseDate !== undefined) { data.expectedCloseDate = parseDate(input.expectedCloseDate); } + if (input.forecastContextManual !== undefined) { + data.forecastContextManual = + input.forecastContextManual === null + ? null + : blankToNull(input.forecastContextManual); + } if (input.amountCents !== undefined || input.currency !== undefined) { const current = await this.db.deal.findUnique({ @@ -478,6 +490,11 @@ export class DealsService { await this.stamp.touch({ companyId: deal.companyId, dealId: deal.id }, now); + void this.agent.dealScore( + deal.id, + `Stage changed from ${deal.stage} to ${input.stage}`, + ); + this.logger.log({ message: "Deal stage changed", dealId: deal.id, diff --git a/apps/api/src/deals/stalled-deals.config.ts b/apps/api/src/deals/stalled-deals.config.ts new file mode 100644 index 000000000..f52230ca5 --- /dev/null +++ b/apps/api/src/deals/stalled-deals.config.ts @@ -0,0 +1,6 @@ +const SECOND_MS = 1_000; +const MINUTE_MS = 60 * SECOND_MS; + +export const STALLED_DEALS = { + auto: { everyMs: 15 * MINUTE_MS, cacheKey: "stalled-deals:auto" }, +} as const; diff --git a/apps/api/src/deals/stalled-deals.service.ts b/apps/api/src/deals/stalled-deals.service.ts new file mode 100644 index 000000000..51ebfb0de --- /dev/null +++ b/apps/api/src/deals/stalled-deals.service.ts @@ -0,0 +1,125 @@ +import { onSignedIn } from "@crm/auth"; +import type { Db } from "@crm/db"; +import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; +import { + daysInactive, + isStalledDeal, + STALLED_DEAL, + stallCutoff, + stallReason, +} from "@crm/db/stalled-deals"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Inject, Injectable, Logger, type OnModuleInit } from "@nestjs/common"; +import type { Cache } from "cache-manager"; +import { AgentTriggerService } from "../agent/agent-trigger.service"; +import { InjectDatabase } from "../database/database.constants"; +import { STALLED_DEALS } from "./stalled-deals.config"; + +export type StalledDealSweepResult = { + scanned: number; + queued: number; + alreadyQueued: number; +}; + +@Injectable() +export class StalledDealsService implements OnModuleInit { + private readonly logger = new Logger(StalledDealsService.name); + + constructor( + @InjectDatabase() private readonly db: Db, + private readonly agent: AgentTriggerService, + @Inject(CACHE_MANAGER) private readonly cache: Cache, + ) {} + + onModuleInit(): void { + onSignedIn(() => { + void this.auto(); + }); + } + + async auto(): Promise<{ started: boolean }> { + if (await this.cache.get(STALLED_DEALS.auto.cacheKey)) { + return { started: false }; + } + await this.cache.set( + STALLED_DEALS.auto.cacheKey, + true, + STALLED_DEALS.auto.everyMs, + ); + + void (async () => { + try { + const result = await this.sweep(); + if (result.queued > 0 || result.scanned > 0) { + this.logger.log({ + message: "Stalled-deal sweep finished", + ...result, + }); + } + } catch (error) { + this.logger.error( + { message: "Stalled-deal sweep failed" }, + error instanceof Error ? error.stack : String(error), + ); + } + })(); + + return { started: true }; + } + + async sweep(now = new Date()): Promise { + const cutoff = stallCutoff(now, STALLED_DEAL.inactiveDays); + + const deals = await this.db.deal.findMany({ + where: { + stage: { in: [...OPEN_DEAL_STAGES] }, + OR: [ + { lastActivityAt: { lte: cutoff } }, + { lastActivityAt: null, createdAt: { lte: cutoff } }, + ], + }, + orderBy: [ + { lastActivityAt: { sort: "asc", nulls: "first" } }, + { createdAt: "asc" }, + { id: "asc" }, + ], + take: STALLED_DEAL.maxPerRun, + select: { + id: true, + name: true, + createdAt: true, + lastActivityAt: true, + }, + }); + + let queued = 0; + let alreadyQueued = 0; + + for (const deal of deals) { + if ( + !isStalledDeal({ + lastActivityAt: deal.lastActivityAt, + createdAt: deal.createdAt, + now, + inactiveDays: STALLED_DEAL.inactiveDays, + }) + ) { + continue; + } + + const days = daysInactive({ + lastActivityAt: deal.lastActivityAt, + createdAt: deal.createdAt, + now, + }); + const created = await this.agent.stalledDeal( + deal.id, + stallReason(deal.name, days), + ); + if (created) queued += 1; + else alreadyQueued += 1; + } + + return { scanned: deals.length, queued, alreadyQueued }; + } +} diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index b42b0326b..ffa0b8ff9 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -69,6 +69,8 @@ const appRouter = t.router({ agents: t.router({ list: publicProcedure .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + observability: publicProcedure + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), revise: publicProcedure .input(agentReviseInput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), diff --git a/apps/api/src/telemetry/rollup.service.ts b/apps/api/src/telemetry/rollup.service.ts index 928cd990c..c19392bc2 100644 --- a/apps/api/src/telemetry/rollup.service.ts +++ b/apps/api/src/telemetry/rollup.service.ts @@ -18,14 +18,20 @@ import { drainCounters, installDaily, type Properties, + permittedAgentActionStatus, + permittedAgentRunStatus, + permittedAgentTriggerType, permittedEvidenceKind, + permittedLifecycleRole, permittedMethod, permittedTaskKind, + permittedTeamActionType, permittedTool, releaseRollup, restoreCounters, telemetryDisabled, } from "@crm/telemetry"; +import { readLifecycleRole } from "@crm/validation"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; import { FunnelService } from "./funnel.service"; @@ -186,7 +192,7 @@ export class RollupService { since: Date, counters: Record, ): Promise { - const [tools, sessions, tasks, attempts, rechecks, conversations] = + const [tools, sessions, tasks, attempts, rechecks, conversations, team] = await Promise.all([ this.toolCalls(since), this.sessions(since), @@ -194,6 +200,7 @@ export class RollupService { this.attempts(since), this.rechecks(since), this.db.agentConversation.count(), + this.teamAgents(since), ]); const total = Object.values(tools.calls).reduce((sum, n) => sum + n, 0); @@ -223,6 +230,130 @@ export class RollupService { recheck_interval_days: rechecks.buckets, agent_conversations: conversations, + + team_runs_by_status: team.runsByStatus, + team_runs_by_trigger: team.runsByTrigger, + team_runs_by_lifecycle_role: team.runsByRole, + team_actions_by_type: team.actionsByType, + team_actions_by_status: team.actionsByStatus, + team_runs_cancelled: team.cancelled, + team_runs_cancel_after_action: team.cancelAfterAction, + team_dependency_failures: team.dependencyFailures, + team_input_tokens: team.inputTokens, + team_output_tokens: team.outputTokens, + team_cost_usd: team.costUsd, + }; + } + + private async teamAgents(since: Date): Promise<{ + runsByStatus: Record; + runsByTrigger: Record; + runsByRole: Record; + actionsByType: Record; + actionsByStatus: Record; + cancelled: number; + cancelAfterAction: number; + dependencyFailures: number; + inputTokens: number; + outputTokens: number; + costUsd: number; + }> { + const [statusRows, triggerRows, runs, actionTypeRows, actionStatusRows] = + await Promise.all([ + this.db.agentRun.groupBy({ + by: ["status"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentRun.groupBy({ + by: ["triggerType"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentRun.findMany({ + where: { createdAt: { gte: since } }, + select: { + status: true, + cancelRequestedAt: true, + errorCode: true, + inputTokens: true, + outputTokens: true, + costUsd: true, + version: { select: { manifest: true } }, + _count: { select: { actions: true } }, + }, + }), + this.db.agentAction.groupBy({ + by: ["type"], + where: { plannedAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentAction.groupBy({ + by: ["status"], + where: { plannedAt: { gte: since } }, + _count: { _all: true }, + }), + ]); + + const runsByRole: Record = {}; + let cancelled = 0; + let cancelAfterAction = 0; + let dependencyFailures = 0; + let inputTokens = 0; + let outputTokens = 0; + let costUsd = 0; + + for (const run of runs) { + const role = readLifecycleRole(run.version.manifest); + const roleKey = role ? permittedLifecycleRole(role) : "none"; + runsByRole[roleKey] = (runsByRole[roleKey] ?? 0) + 1; + + if (run.status === "CANCELLED" || run.cancelRequestedAt) { + cancelled += 1; + if (run._count.actions > 0) cancelAfterAction += 1; + } + + if (run.errorCode === "DEPENDENCY_UNAVAILABLE") { + dependencyFailures += 1; + } + + inputTokens += run.inputTokens ?? 0; + outputTokens += run.outputTokens ?? 0; + costUsd += Number(run.costUsd ?? 0); + } + + return { + runsByStatus: merge( + statusRows.map((row) => ({ + key: permittedAgentRunStatus(row.status), + count: row._count._all, + })), + ), + runsByTrigger: merge( + triggerRows.map((row) => ({ + key: permittedAgentTriggerType(row.triggerType), + count: row._count._all, + })), + ), + runsByRole, + actionsByType: merge( + actionTypeRows.map((row) => ({ + key: permittedTeamActionType(row.type), + count: row._count._all, + })), + ), + actionsByStatus: merge( + actionStatusRows.map((row) => ({ + key: permittedAgentActionStatus(row.status), + count: row._count._all, + })), + ), + cancelled, + cancelAfterAction, + dependencyFailures, + inputTokens, + outputTokens, + costUsd: round(costUsd), }; } diff --git a/apps/api/test/agent-observability.spec.ts b/apps/api/test/agent-observability.spec.ts new file mode 100644 index 000000000..e073cb597 --- /dev/null +++ b/apps/api/test/agent-observability.spec.ts @@ -0,0 +1,181 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth"; +import { db } from "@crm/db"; +import { workspaceSlug } from "@crm/db/workspace"; +import { AgentAccessService } from "../src/agent/agent-access.service"; +import { AgentObservabilityService } from "../src/agent/agent-observability.service"; + +const suffix = crypto.randomUUID(); +const userId = `agent-obs-user-${suffix}`; +const memberId = `agent-obs-member-${suffix}`; +let agentId = ""; +let versionId = ""; +const service = new AgentObservabilityService(db, new AgentAccessService(db)); + +beforeAll(async () => { + await db.organization.upsert({ + where: { id: WORKSPACE_ID }, + update: {}, + create: { + id: WORKSPACE_ID, + name: DEFAULT_WORKSPACE_NAME, + slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), + createdAt: new Date(), + }, + }); + await db.user.create({ + data: { + id: userId, + name: "Agent Obs Test", + email: `${userId}@example.test`, + }, + }); + await db.member.create({ + data: { + id: memberId, + organizationId: WORKSPACE_ID, + userId, + role: "member", + createdAt: new Date(), + }, + }); + const agent = await db.agentDefinition.create({ + data: { name: "Obs fleet", status: "LIVE", createdById: userId }, + select: { id: true }, + }); + agentId = agent.id; + const version = await db.agentVersion.create({ + data: { + agentId, + number: 1, + status: "DEPLOYED", + instructions: "Report health only.", + manifest: { + lifecycleRole: "qualify", + actions: [{ type: "run.summary", provider: "crm", summary: "done" }], + dataScope: { mode: "WORKSPACE", summary: "workspace", resources: [] }, + }, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }, + select: { id: true }, + }); + versionId = version.id; + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: versionId }, + }); + + const succeeded = await db.agentRun.create({ + data: { + agentId, + versionId, + triggerType: "MANUAL", + status: "SUCCEEDED", + idempotencyKey: `obs-ok-${suffix}`, + correlationId: `obs-ok-corr-${suffix}`, + inputTokens: 10, + outputTokens: 5, + costUsd: "0.01", + sessionId: `session-ok-${suffix}`, + startedAt: new Date(), + finishedAt: new Date(), + }, + select: { id: true }, + }); + await db.agentAction.create({ + data: { + agentId, + runId: succeeded.id, + type: "crm.activity.create", + provider: "crm", + summary: "NOTE only", + status: "SUCCEEDED", + idempotencyKey: `obs-action-${suffix}`, + }, + }); + await db.agentRun.create({ + data: { + agentId, + versionId, + triggerType: "EVENT", + status: "FAILED", + idempotencyKey: `obs-dep-${suffix}`, + correlationId: `obs-dep-corr-${suffix}`, + errorCode: "DEPENDENCY_UNAVAILABLE", + errorMessage: "Slack missing", + finishedAt: new Date(), + }, + }); + const cancelled = await db.agentRun.create({ + data: { + agentId, + versionId, + triggerType: "SCHEDULE", + status: "CANCELLED", + idempotencyKey: `obs-cancel-${suffix}`, + correlationId: `obs-cancel-corr-${suffix}`, + cancelRequestedAt: new Date(), + finishedAt: new Date(), + }, + select: { id: true }, + }); + await db.agentAction.create({ + data: { + agentId, + runId: cancelled.id, + type: "run.summary", + provider: "crm", + summary: "partial", + status: "SUCCEEDED", + idempotencyKey: `obs-cancel-action-${suffix}`, + }, + }); +}); + +afterAll(async () => { + if (agentId) { + await db.agentAction.deleteMany({ where: { agentId } }); + await db.agentRunEvent.deleteMany({ + where: { run: { agentId } }, + }); + await db.agentRun.deleteMany({ where: { agentId } }); + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ where: { agentId } }); + await db.agentDefinition.deleteMany({ where: { id: agentId } }); + } + await db.member.deleteMany({ where: { id: memberId } }); + await db.user.deleteMany({ where: { id: userId } }); +}); + +describe("agent observability fleet", () => { + it("aggregates status, role, quality, and consumption without free text", async () => { + const fleet = await service.fleet(userId); + + expect(fleet.windowHours).toBe(24); + expect(fleet.runsByStatus.SUCCEEDED).toBeGreaterThanOrEqual(1); + expect(fleet.runsByStatus.FAILED).toBeGreaterThanOrEqual(1); + expect(fleet.runsByStatus.CANCELLED).toBeGreaterThanOrEqual(1); + expect(fleet.runsByTrigger.MANUAL).toBeGreaterThanOrEqual(1); + expect(fleet.runsByLifecycleRole.qualify).toBeGreaterThanOrEqual(3); + expect(fleet.actionsByType["crm.activity.create"]).toBeGreaterThanOrEqual( + 1, + ); + expect(fleet.quality.dependencyFailures).toBeGreaterThanOrEqual(1); + expect(fleet.quality.cancelAfterAction).toBeGreaterThanOrEqual(1); + expect(fleet.consumption.inputTokens).toBeGreaterThanOrEqual(10); + expect(fleet.consumption.outputTokens).toBeGreaterThanOrEqual(5); + expect(fleet.consumption.costUsd).toBeGreaterThanOrEqual(0.01); + expect(fleet.consumption.sessionsWithTrace).toBeGreaterThanOrEqual(1); + expect(fleet.agents.byLifecycleRole.qualify).toBeGreaterThanOrEqual(1); + + const encoded = JSON.stringify(fleet); + expect(encoded).not.toContain("NOTE only"); + expect(encoded).not.toContain("Slack missing"); + expect(encoded).not.toContain("Report health only"); + }); +}); diff --git a/apps/api/test/calendar-client.spec.ts b/apps/api/test/calendar-client.spec.ts new file mode 100644 index 000000000..f13ea02e1 --- /dev/null +++ b/apps/api/test/calendar-client.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test"; +import { + conferenceUrl, + eventTime, + type GoogleEvent, +} from "../src/google/calendar.client"; + +describe("eventTime", () => { + it("parses a timed event", () => { + const parsed = eventTime({ dateTime: "2026-08-15T14:30:00Z" }); + expect(parsed?.isAllDay).toBe(false); + expect(parsed?.at.toISOString()).toBe("2026-08-15T14:30:00.000Z"); + }); + + it("parses an all-day event as UTC midnight", () => { + const parsed = eventTime({ date: "2026-08-15" }); + expect(parsed?.isAllDay).toBe(true); + expect(parsed?.at.toISOString()).toBe("2026-08-15T00:00:00.000Z"); + }); + + it("rejects empty and invalid values", () => { + expect(eventTime(undefined)).toBeNull(); + expect(eventTime({})).toBeNull(); + expect(eventTime({ dateTime: "not-a-date" })).toBeNull(); + }); +}); + +describe("conferenceUrl", () => { + it("prefers hangoutLink", () => { + const event: GoogleEvent = { + hangoutLink: "https://meet.google.com/abc", + conferenceData: { + entryPoints: [ + { entryPointType: "video", uri: "https://zoom.example/x" }, + ], + }, + }; + expect(conferenceUrl(event)).toBe("https://meet.google.com/abc"); + }); + + it("falls back to the video conference entry", () => { + const event: GoogleEvent = { + conferenceData: { + entryPoints: [ + { entryPointType: "phone", uri: "tel:+1555" }, + { entryPointType: "video", uri: "https://zoom.example/x" }, + ], + }, + }; + expect(conferenceUrl(event)).toBe("https://zoom.example/x"); + }); + + it("returns null when nothing is present", () => { + expect(conferenceUrl({})).toBeNull(); + }); +}); diff --git a/apps/api/test/calendar-sync.spec.ts b/apps/api/test/calendar-sync.spec.ts new file mode 100644 index 000000000..276589583 --- /dev/null +++ b/apps/api/test/calendar-sync.spec.ts @@ -0,0 +1,351 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { + ActivityType, + db, + GoogleSyncStatus, + type MailboxSyncModel as MailboxSync, +} from "@crm/db"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { CompanyDirectoryService } from "../src/companies/company-directory.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { EnrichmentLogService } from "../src/crm/enrichment-log.service"; +import type { + CalendarClient, + EventsPage, + GoogleEvent, +} from "../src/google/calendar.client"; +import { CalendarSyncService } from "../src/google/calendar-sync.service"; +import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; +import type { MailboxTokenService } from "../src/mailbox/mailbox-token.service"; +import { SyncStateService } from "../src/mailbox/sync-state.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "calendar-sync-spec"; +const domain = `cal-${suffix}.test`; +const userId = `user-cal-${suffix}`; +const mailbox = `rep-cal-${suffix}@example.test`; +const person = `buyer@${domain}`; +const iCalUid = `uid-${suffix}@google.com`; +const startsAt = "2026-09-01T15:00:00.000Z"; +const endsAt = "2026-09-01T16:00:00.000Z"; + +const agent = { + contactCreated: async () => undefined, + companyCreated: async () => undefined, + meetingSoon: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, + companyRequested: async () => undefined, +} as unknown as AgentTriggerService; + +const stamp = new ActivityStampService(db); +const directory = new CompanyDirectoryService(agent); +const log = new EnrichmentLogService(db, stamp); +const match = new MailboxMatchService(db, directory, agent, log); +const state = new SyncStateService(db); + +let row: MailboxSync; +let companyId: string; +let contactId: string; +let pages: EventsPage[] = []; +let listCalls = 0; + +const tokens = { + async accessTokenFor() { + return { outcome: "ok" as const, accessToken: "token" }; + }, +} as unknown as MailboxTokenService; + +const calendar = { + async listEvents() { + listCalls += 1; + const page = pages.shift() ?? { items: [] }; + return { outcome: "ok" as const, data: page }; + }, +} as unknown as CalendarClient; + +const service = new CalendarSyncService( + db, + calendar, + tokens, + match, + state, + stamp, + agent, +); + +function event(overrides: Partial = {}): GoogleEvent { + return { + id: `gcal-${suffix}`, + iCalUID: iCalUid, + status: "confirmed", + summary: "Pricing review", + location: "Zoom", + start: { dateTime: startsAt }, + end: { dateTime: endsAt }, + organizer: { email: person, displayName: "A Buyer" }, + attendees: [ + { + email: person, + displayName: "A Buyer", + responseStatus: "accepted", + organizer: true, + }, + { + email: mailbox, + displayName: "Test Rep", + responseStatus: "accepted", + self: true, + }, + ], + ...overrides, + }; +} + +async function clean() { + await db.activity.deleteMany({ + where: { + OR: [ + { createdById: userId }, + { calendarEvent: { iCalUid } }, + { calendarEvent: { iCalUid: `${iCalUid}-other` } }, + ], + }, + }); + await db.calendarAttendee.deleteMany({ + where: { + event: { + OR: [ + { iCalUid }, + { iCalUid: `${iCalUid}-other` }, + { syncedByUserId: userId }, + ], + }, + }, + }); + await db.calendarEvent.deleteMany({ + where: { + OR: [ + { iCalUid }, + { iCalUid: `${iCalUid}-other` }, + { syncedByUserId: userId }, + ], + }, + }); + await db.contact.deleteMany({ where: { email: person } }); + await db.company.deleteMany({ where: { domain } }); + await db.mailboxSync.deleteMany({ where: { userId } }); + await db.user.deleteMany({ where: { id: userId } }); +} + +beforeAll(async () => { + await clean(); + + await db.user.create({ + data: { id: userId, name: "Test Rep", email: mailbox }, + }); + row = await db.mailboxSync.create({ + data: { + userId, + source: "calendar", + autoCreate: false, + status: GoogleSyncStatus.IDLE, + }, + }); + + const company = await db.company.create({ + data: { name: "Buyer Co", domain }, + select: { id: true }, + }); + companyId = company.id; + + const contact = await db.contact.create({ + data: { + firstName: "A", + lastName: "Buyer", + email: person, + companyId, + }, + select: { id: true }, + }); + contactId = contact.id; +}); + +afterAll(clean); + +describe("CalendarSyncService", () => { + it("projects a relevant meeting onto the company timeline", async () => { + pages = [{ items: [event()], nextSyncToken: "sync-1" }]; + listCalls = 0; + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.eventsWritten).toBe(1); + + const stored = await db.calendarEvent.findUnique({ + where: { + iCalUid_originalStartTime: { + iCalUid, + originalStartTime: new Date(startsAt), + }, + }, + include: { + activity: true, + attendees: { orderBy: { email: "asc" } }, + }, + }); + + expect(stored).not.toBeNull(); + expect(stored?.companyId).toBe(companyId); + expect(stored?.contactId).toBe(contactId); + expect(stored?.activity?.type).toBe(ActivityType.MEETING); + expect(stored?.activity?.subject).toBe("Pricing review"); + expect(stored?.activity?.companyId).toBe(companyId); + expect(stored?.activity?.occurredAt?.toISOString()).toBe(startsAt); + expect(stored?.attendees.map((a) => a.email)).toEqual( + [mailbox, person].sort(), + ); + + const refreshed = await db.mailboxSync.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(refreshed.cursor).toBe("sync-1"); + row = refreshed; + }); + + it("dedupes on (iCalUID, originalStartTime) when the same window re-runs", async () => { + pages = [ + { + items: [event({ summary: "Pricing review (updated)", location: "HQ" })], + nextSyncToken: "sync-2", + }, + ]; + + const before = await db.calendarEvent.count({ where: { iCalUid } }); + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.eventsWritten).toBe(1); + + const after = await db.calendarEvent.count({ where: { iCalUid } }); + expect(after).toBe(before); + expect(after).toBe(1); + + const stored = await db.calendarEvent.findFirst({ + where: { iCalUid }, + include: { activity: true }, + }); + expect(stored?.title).toBe("Pricing review (updated)"); + expect(stored?.location).toBe("HQ"); + expect(stored?.activity?.subject).toBe("Pricing review (updated)"); + expect(stored?.activity?.body).toBe("Location: HQ"); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + }); + + it("ignores events with no tracked company or contact", async () => { + const strangerUid = `${iCalUid}-other`; + pages = [ + { + items: [ + event({ + iCalUID: strangerUid, + id: `gcal-stranger-${suffix}`, + organizer: { + email: "stranger@unknown-host.invalid", + displayName: "Nobody", + }, + attendees: [ + { + email: "stranger@unknown-host.invalid", + responseStatus: "accepted", + organizer: true, + }, + { + email: mailbox, + responseStatus: "accepted", + self: true, + }, + ], + }), + ], + nextSyncToken: "sync-3", + }, + ]; + + const before = await db.calendarEvent.count(); + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.eventsWritten ?? 0).toBe(0); + expect(await db.calendarEvent.count()).toBe(before); + expect( + await db.calendarEvent.count({ where: { iCalUid: strangerUid } }), + ).toBe(0); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + }); + + it("removes the projected activity when the event is cancelled", async () => { + pages = [ + { + items: [event({ status: "cancelled" })], + nextSyncToken: "sync-4", + }, + ]; + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.eventsRemoved).toBe(1); + expect(await db.calendarEvent.count({ where: { iCalUid } })).toBe(0); + expect( + await db.activity.count({ + where: { + type: ActivityType.MEETING, + createdById: userId, + subject: { contains: "Pricing" }, + }, + }), + ).toBe(0); + }); + + it("clears the cursor on 410 so the next tick re-reads from now", async () => { + await db.mailboxSync.update({ + where: { id: row.id }, + data: { cursor: "stale-token", status: GoogleSyncStatus.IDLE }, + }); + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + + const failingCalendar = { + async listEvents() { + return { + outcome: "cursor-invalid" as const, + reason: "Sync token is no longer valid.", + }; + }, + } as unknown as CalendarClient; + + const failing = new CalendarSyncService( + db, + failingCalendar, + tokens, + match, + state, + stamp, + agent, + ); + + const outcome = await failing.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.reason).toContain("Cursor reset"); + + const refreshed = await db.mailboxSync.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(refreshed.cursor).toBeNull(); + expect(refreshed.status).toBe(GoogleSyncStatus.IDLE); + row = refreshed; + }); +}); diff --git a/apps/api/test/deal-score.spec.ts b/apps/api/test/deal-score.spec.ts new file mode 100644 index 000000000..aaba8e8d4 --- /dev/null +++ b/apps/api/test/deal-score.spec.ts @@ -0,0 +1,191 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DealStage, db } from "@crm/db"; +import { DEAL_SCORE } from "@crm/db/deal-score"; +import { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { DealScoreService } from "../src/deals/deal-score.service"; + +const suffix = process.env.TEST_RUN_ID ?? crypto.randomUUID().slice(0, 8); +const domain = `deal-score-${suffix}.test`; +const ownerId = `deal-score-owner-${suffix}`; +const now = new Date("2026-08-12T12:00:00.000Z"); +const staleScoredAt = new Date("2026-08-10T12:00:00.000Z"); +const freshScoredAt = new Date("2026-08-12T08:00:00.000Z"); + +const agent = new AgentTriggerService(db); +const cache = { + store: new Map(), + async get(key: string) { + return this.store.get(key); + }, + async set(key: string, value: unknown) { + this.store.set(key, value); + }, +}; +const service = new DealScoreService(db, agent, cache as never); + +let companyId = ""; +let unscoredDealId = ""; +let staleDealId = ""; +let freshDealId = ""; +let closedDealId = ""; +let previousBridgeSecret: string | undefined; + +async function clean() { + const deals = [unscoredDealId, staleDealId, freshDealId, closedDealId].filter( + Boolean, + ); + if (deals.length > 0) { + await db.agentTask.deleteMany({ where: { dealId: { in: deals } } }); + await db.deal.deleteMany({ where: { id: { in: deals } } }); + } + await db.company.deleteMany({ where: { domain } }); + await db.user.deleteMany({ where: { id: ownerId } }); +} + +beforeAll(async () => { + previousBridgeSecret = process.env.AGENT_BRIDGE_SECRET; + delete process.env.AGENT_BRIDGE_SECRET; + await clean(); + + await db.user.create({ + data: { + id: ownerId, + name: "Deal Score Owner", + email: `${ownerId}@example.test`, + emailVerified: true, + }, + }); + + const company = await db.company.create({ + data: { name: `Score Co ${suffix}`, domain }, + select: { id: true }, + }); + companyId = company.id; + + const unscored = await db.deal.create({ + data: { + name: `Unscored ${suffix}`, + companyId, + ownerId, + stage: DealStage.DEMO_BOOKED, + }, + select: { id: true }, + }); + unscoredDealId = unscored.id; + + const stale = await db.deal.create({ + data: { + name: `Stale score ${suffix}`, + companyId, + ownerId, + stage: DealStage.QUALIFIED_TO_BUY, + dealScore: 40, + dealScoreSummary: "Old summary that needs refresh.", + dealScoredAt: staleScoredAt, + forecastContext: "Old forecast.", + }, + select: { id: true }, + }); + staleDealId = stale.id; + + const fresh = await db.deal.create({ + data: { + name: `Fresh score ${suffix}`, + companyId, + ownerId, + stage: DealStage.CONTRACT_SENT, + dealScore: 80, + dealScoreSummary: "Still fresh enough.", + dealScoredAt: freshScoredAt, + forecastContext: "Fresh forecast.", + }, + select: { id: true }, + }); + freshDealId = fresh.id; + + const closed = await db.deal.create({ + data: { + name: `Closed score ${suffix}`, + companyId, + ownerId, + stage: DealStage.CLOSED_WON, + closedAt: staleScoredAt, + dealScoredAt: null, + }, + select: { id: true }, + }); + closedDealId = closed.id; +}); + +afterAll(async () => { + await clean(); + if (previousBridgeSecret === undefined) { + delete process.env.AGENT_BRIDGE_SECRET; + } else { + process.env.AGENT_BRIDGE_SECRET = previousBridgeSecret; + } +}); + +describe("deal-score enqueue", () => { + it("queues open deals that lack a score or are past the rescore window", async () => { + const first = await service.sweep(now); + + expect(first.scanned).toBeGreaterThanOrEqual(2); + expect(first.queued).toBeGreaterThanOrEqual(2); + + const tasks = await db.agentTask.findMany({ + where: { + kind: DEAL_SCORE.kind, + dealId: { + in: [unscoredDealId, staleDealId, freshDealId, closedDealId], + }, + finishedAt: null, + }, + select: { dealId: true, priority: true }, + }); + + const ids = new Set(tasks.map((task) => task.dealId)); + expect(ids.has(unscoredDealId)).toBe(true); + expect(ids.has(staleDealId)).toBe(true); + expect(ids.has(freshDealId)).toBe(false); + expect(ids.has(closedDealId)).toBe(false); + expect(tasks.every((task) => task.priority === 80)).toBe(true); + }); + + it("does not double-queue while a deal-score task is open", async () => { + const second = await service.sweep(now); + + expect(second.queued).toBe(0); + expect(second.alreadyQueued).toBeGreaterThanOrEqual(2); + + const open = await db.agentTask.count({ + where: { + kind: DEAL_SCORE.kind, + dealId: { in: [unscoredDealId, staleDealId] }, + finishedAt: null, + }, + }); + expect(open).toBe(2); + }); + + it("queues a single deal-score task from the trigger helper", async () => { + await db.agentTask.deleteMany({ + where: { kind: DEAL_SCORE.kind, dealId: freshDealId }, + }); + + const created = await agent.dealScore(freshDealId, "Stage changed"); + const again = await agent.dealScore(freshDealId, "Stage changed again"); + + expect(created).toBe(true); + expect(again).toBe(false); + + const open = await db.agentTask.count({ + where: { + kind: DEAL_SCORE.kind, + dealId: freshDealId, + finishedAt: null, + }, + }); + expect(open).toBe(1); + }); +}); diff --git a/apps/api/test/gmail-sync.spec.ts b/apps/api/test/gmail-sync.spec.ts new file mode 100644 index 000000000..1667d6fcd --- /dev/null +++ b/apps/api/test/gmail-sync.spec.ts @@ -0,0 +1,625 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { + ActivityType, + db, + GoogleSyncStatus, + type MailboxSyncModel as MailboxSync, +} from "@crm/db"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { CompanyDirectoryService } from "../src/companies/company-directory.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { EnrichmentLogService } from "../src/crm/enrichment-log.service"; +import { ConversationService } from "../src/google/conversation.service"; +import type { GmailClient, GmailMessage } from "../src/google/gmail.client"; +import { GmailSyncService } from "../src/google/gmail-sync.service"; +import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; +import type { MailboxTokenService } from "../src/mailbox/mailbox-token.service"; +import { SyncStateService } from "../src/mailbox/sync-state.service"; +import { ThreadWriterService } from "../src/mailbox/thread-writer.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "gmail-sync-spec"; +const domain = `gmail-${suffix}.test`; +const userId = `user-gmail-${suffix}`; +const secondUserId = `user-gmail-b-${suffix}`; +const mailbox = `rep-gmail-${suffix}@example.test`; +const secondMailbox = `rep-b-gmail-${suffix}@example.test`; +const person = `buyer@${domain}`; +const rootRfc = ``; +const replyRfc = ``; +const rootId = `root-${suffix}@mail.test`; +const replyId = `reply-${suffix}@mail.test`; +const bodyText = [ + "Here are the numbers you asked for on the Q3 order.", + "Please review the line items, the volume discount table,", + "and the shipping schedule before Friday so we can lock the PO.", + "I also attached the competitor matrix from last week for context.", +].join(" "); + +const agent = { + contactCreated: async () => undefined, + companyCreated: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, + companyRequested: async () => undefined, +} as unknown as AgentTriggerService; + +const stamp = new ActivityStampService(db); +const directory = new CompanyDirectoryService(agent); +const log = new EnrichmentLogService(db, stamp); +const match = new MailboxMatchService(db, directory, agent, log); +const state = new SyncStateService(db); +const threads = new ThreadWriterService(db, match, stamp); +const conversations = new ConversationService(db); + +let row: MailboxSync; +let secondRow: MailboxSync; +let companyId: string; +let contactId: string; + +let messagesById = new Map(); +let historyOutcome: + | { outcome: "ok"; data: { history?: unknown[]; historyId?: string } } + | { outcome: "cursor-invalid"; reason: string } = { + outcome: "ok", + data: { history: [], historyId: "hist-0" }, +}; +let profileHistoryId = "hist-start"; + +const tokens = { + async accessTokenFor() { + return { outcome: "ok" as const, accessToken: "token" }; + }, +} as unknown as MailboxTokenService; + +const gmail = { + async profile() { + return { + outcome: "ok" as const, + data: { emailAddress: mailbox, historyId: profileHistoryId }, + }; + }, + async listHistory() { + return historyOutcome; + }, + async getMessage(_token: string, id: string) { + const data = messagesById.get(id); + if (!data) { + return { + outcome: "failed" as const, + reason: "missing", + retryable: false, + }; + } + return { outcome: "ok" as const, data }; + }, +} as unknown as GmailClient; + +const service = new GmailSyncService(db, gmail, tokens, state, threads); + +function encode(text: string): string { + return Buffer.from(text, "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function gmailMessage(options: { + id: string; + rfcMessageId: string; + from: string; + fromName?: string; + to: string; + subject: string; + body: string; + sentAt: string; + references?: string; + inReplyTo?: string; + threadId?: string; +}): GmailMessage { + const headers = [ + { name: "Message-ID", value: options.rfcMessageId }, + { + name: "From", + value: options.fromName + ? `${options.fromName} <${options.from}>` + : options.from, + }, + { name: "To", value: options.to }, + { name: "Subject", value: options.subject }, + { name: "Date", value: options.sentAt }, + ]; + if (options.references) { + headers.push({ name: "References", value: options.references }); + } + if (options.inReplyTo) { + headers.push({ name: "In-Reply-To", value: options.inReplyTo }); + } + + return { + id: options.id, + threadId: options.threadId ?? `gmail-thread-${options.id}`, + internalDate: String(new Date(options.sentAt).getTime()), + payload: { + mimeType: "text/plain", + headers, + body: { data: encode(options.body) }, + }, + }; +} + +function setHistory(addedIds: string[], historyId: string) { + historyOutcome = { + outcome: "ok", + data: { + historyId, + history: [ + { + id: historyId, + messagesAdded: addedIds.map((id) => ({ message: { id } })), + }, + ], + }, + }; +} + +async function clean() { + await db.emailMessage.deleteMany({ + where: { + OR: [ + { rfcMessageId: { contains: suffix } }, + { gmailMessageId: { contains: suffix } }, + { thread: { rootMessageId: { contains: suffix } } }, + ], + }, + }); + await db.activity.deleteMany({ + where: { + OR: [ + { createdById: { in: [userId, secondUserId] } }, + { emailThread: { rootMessageId: { contains: suffix } } }, + ], + }, + }); + await db.emailThread.deleteMany({ + where: { rootMessageId: { contains: suffix } }, + }); + await db.contact.deleteMany({ where: { email: person } }); + await db.company.deleteMany({ where: { domain } }); + await db.mailboxSync.deleteMany({ + where: { userId: { in: [userId, secondUserId] } }, + }); + await db.user.deleteMany({ where: { id: { in: [userId, secondUserId] } } }); +} + +beforeAll(async () => { + await clean(); + + await db.user.create({ + data: { id: userId, name: "Test Rep", email: mailbox }, + }); + await db.user.create({ + data: { id: secondUserId, name: "Other Rep", email: secondMailbox }, + }); + + row = await db.mailboxSync.create({ + data: { + userId, + source: "gmail", + autoCreate: false, + status: GoogleSyncStatus.IDLE, + cursor: "hist-ready", + }, + }); + secondRow = await db.mailboxSync.create({ + data: { + userId: secondUserId, + source: "gmail", + autoCreate: false, + status: GoogleSyncStatus.IDLE, + cursor: "hist-ready-b", + }, + }); + + const company = await db.company.create({ + data: { name: "Buyer Co", domain }, + select: { id: true }, + }); + companyId = company.id; + + const contact = await db.contact.create({ + data: { + firstName: "A", + lastName: "Buyer", + email: person, + companyId, + }, + select: { id: true }, + }); + contactId = contact.id; +}); + +afterAll(clean); + +describe("GmailSyncService", () => { + it("stamps the historyId on first sight and imports nothing", async () => { + await db.mailboxSync.update({ + where: { id: row.id }, + data: { cursor: null, status: GoogleSyncStatus.IDLE }, + }); + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + profileHistoryId = "hist-first-sight"; + messagesById = new Map(); + setHistory(["should-not-fetch"], "hist-ignored"); + + const beforeThreads = await db.emailThread.count({ + where: { rootMessageId: { contains: suffix } }, + }); + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten).toBeUndefined(); + + const refreshed = await db.mailboxSync.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(refreshed.cursor).toBe("hist-first-sight"); + expect( + await db.emailThread.count({ + where: { rootMessageId: { contains: suffix } }, + }), + ).toBe(beforeThreads); + + row = refreshed; + }); + + it("projects a relevant thread onto the company timeline", async () => { + const gmailId = `gm-${suffix}-1`; + const message = gmailMessage({ + id: gmailId, + rfcMessageId: rootRfc, + from: mailbox, + fromName: "Test Rep", + to: person, + subject: "Pricing", + body: bodyText, + sentAt: "2026-08-10T14:00:00.000Z", + }); + messagesById = new Map([[gmailId, message]]); + setHistory([gmailId], "hist-1"); + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten).toBe(1); + + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: rootId }, + include: { + activity: true, + messages: true, + }, + }); + + expect(thread).not.toBeNull(); + expect(thread?.companyId).toBe(companyId); + expect(thread?.contactId).toBe(contactId); + expect(thread?.messageCount).toBe(1); + expect(thread?.activity?.type).toBe(ActivityType.EMAIL); + expect(thread?.activity?.subject).toBe("Pricing"); + expect(thread?.activity?.companyId).toBe(companyId); + expect(thread?.activity?.body?.endsWith("…")).toBe(true); + expect(thread?.activity?.body?.length).toBeLessThanOrEqual(200); + expect(thread?.messages[0]?.gmailMessageId).toBe(gmailId); + expect(thread?.messages[0]?.rfcMessageId).toBe(rootId); + expect(thread?.messages[0]?.body).toBe(bodyText); + + const meta = thread?.activity?.meta as Record | null; + expect(meta?.synced).toBe(true); + expect(meta?.source).toBe("gmail"); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + expect(row.cursor).toBe("hist-1"); + }); + + it("dedupes on rfcMessageId across two mailboxes with different Gmail ids", async () => { + const otherGmailId = `gm-${suffix}-1b`; + const duplicate = gmailMessage({ + id: otherGmailId, + rfcMessageId: rootRfc, + from: mailbox, + fromName: "Test Rep", + to: person, + subject: "Pricing", + body: bodyText, + sentAt: "2026-08-10T14:00:00.000Z", + threadId: `other-mailbox-thread-${suffix}`, + }); + + const secondGmail = { + async profile() { + return { + outcome: "ok" as const, + data: { + emailAddress: secondMailbox, + historyId: "hist-b-start", + }, + }; + }, + async listHistory() { + return { + outcome: "ok" as const, + data: { + historyId: "hist-b-1", + history: [ + { + id: "hist-b-1", + messagesAdded: [{ message: { id: otherGmailId } }], + }, + ], + }, + }; + }, + async getMessage() { + return { outcome: "ok" as const, data: duplicate }; + }, + } as unknown as GmailClient; + + const otherService = new GmailSyncService( + db, + secondGmail, + tokens, + state, + threads, + ); + + const beforeMessages = await db.emailMessage.count({ + where: { rfcMessageId: rootId }, + }); + const beforeThreads = await db.emailThread.count({ + where: { rootMessageId: rootId }, + }); + const beforeActivities = await db.activity.count({ + where: { + type: ActivityType.EMAIL, + emailThread: { rootMessageId: rootId }, + }, + }); + + const outcome = await otherService.sync(secondRow); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten ?? 0).toBe(0); + expect( + await db.emailMessage.count({ where: { rfcMessageId: rootId } }), + ).toBe(beforeMessages); + expect( + await db.emailThread.count({ where: { rootMessageId: rootId } }), + ).toBe(beforeThreads); + expect( + await db.activity.count({ + where: { + type: ActivityType.EMAIL, + emailThread: { rootMessageId: rootId }, + }, + }), + ).toBe(beforeActivities); + expect(beforeMessages).toBe(1); + expect(beforeThreads).toBe(1); + expect(beforeActivities).toBe(1); + }); + + it("keeps full bodies out of the timeline list path and returns them on expand", async () => { + const thread = await db.emailThread.findUniqueOrThrow({ + where: { rootMessageId: rootId }, + select: { id: true }, + }); + + const list = await db.activity.findMany({ + where: { emailThreadId: thread.id }, + select: { + id: true, + subject: true, + body: true, + emailThread: { + select: { + id: true, + messageCount: true, + lastMessageAt: true, + }, + }, + }, + }); + + expect(list).toHaveLength(1); + expect(list[0]?.emailThread).not.toBeNull(); + expect(list[0]?.body?.endsWith("…")).toBe(true); + expect(list[0]?.body?.length).toBeLessThanOrEqual(200); + expect(list[0]?.body).not.toBe(bodyText); + expect(JSON.stringify(list[0]?.emailThread)).not.toContain( + "volume discount table", + ); + + const expanded = await conversations.thread(thread.id); + expect(expanded.messages.length).toBe(1); + expect(expanded.messages[0]?.body).toBe(bodyText); + expect(expanded.messages[0]?.mailboxName).toBe("Gmail"); + expect(expanded.messages[0]?.mailboxUrl).toContain("mail.google.com"); + }); + + it("updates the projected activity when a new message arrives on the thread", async () => { + const gmailId = `gm-${suffix}-2`; + const reply = gmailMessage({ + id: gmailId, + rfcMessageId: replyRfc, + from: person, + fromName: "A Buyer", + to: mailbox, + subject: "Re: Pricing", + body: "Thanks — can we also get the volume discount table?", + sentAt: "2026-08-10T16:30:00.000Z", + references: rootRfc, + inReplyTo: rootRfc, + }); + messagesById = new Map([[gmailId, reply]]); + setHistory([gmailId], "hist-2"); + + const gmailForInbound = { + ...gmail, + async profile() { + return { + outcome: "ok" as const, + data: { emailAddress: mailbox, historyId: "hist-2" }, + }; + }, + } as unknown as GmailClient; + + const inboundService = new GmailSyncService( + db, + gmailForInbound, + tokens, + state, + threads, + ); + + const outcome = await inboundService.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten).toBe(1); + + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: rootId }, + include: { activity: true, messages: true }, + }); + + expect(thread?.messageCount).toBe(2); + expect(thread?.messages).toHaveLength(2); + expect(thread?.activity?.body).toContain("volume discount"); + expect(thread?.activity?.occurredAt?.toISOString()).toBe( + "2026-08-10T16:30:00.000Z", + ); + expect( + thread?.messages.some((message) => message.rfcMessageId === replyId), + ).toBe(true); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + }); + + it("ignores mail that does not resolve to a tracked company when auto-create is off", async () => { + const strangerId = `gm-${suffix}-stranger`; + const strangerRfc = ``; + const strangerKey = `stranger-${suffix}@mail.test`; + const stranger = gmailMessage({ + id: strangerId, + rfcMessageId: strangerRfc, + from: mailbox, + to: "nobody@unknown-host.invalid", + subject: "Hello stranger", + body: "This should never land in the CRM.", + sentAt: "2026-08-11T10:00:00.000Z", + }); + messagesById = new Map([[strangerId, stranger]]); + setHistory([strangerId], "hist-3"); + + const before = await db.emailThread.count({ + where: { rootMessageId: strangerKey }, + }); + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten ?? 0).toBe(0); + expect( + await db.emailThread.count({ where: { rootMessageId: strangerKey } }), + ).toBe(before); + expect( + await db.emailMessage.count({ where: { rfcMessageId: strangerKey } }), + ).toBe(0); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + }); + + it("is idempotent when the same history window is re-run", async () => { + const gmailId = `gm-${suffix}-1`; + const message = gmailMessage({ + id: gmailId, + rfcMessageId: rootRfc, + from: mailbox, + to: person, + subject: "Pricing", + body: bodyText, + sentAt: "2026-08-10T14:00:00.000Z", + }); + messagesById = new Map([[gmailId, message]]); + setHistory([gmailId], "hist-4"); + + const beforeMessages = await db.emailMessage.count({ + where: { thread: { rootMessageId: rootId } }, + }); + const beforeThreads = await db.emailThread.count({ + where: { rootMessageId: rootId }, + }); + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten ?? 0).toBe(0); + expect( + await db.emailMessage.count({ + where: { thread: { rootMessageId: rootId } }, + }), + ).toBe(beforeMessages); + expect( + await db.emailThread.count({ where: { rootMessageId: rootId } }), + ).toBe(beforeThreads); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + }); + + it("clears the cursor on history 404 so the next tick resumes from now", async () => { + await db.mailboxSync.update({ + where: { id: row.id }, + data: { cursor: "stale-history", status: GoogleSyncStatus.IDLE }, + }); + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + + const failingGmail = { + async profile() { + return { + outcome: "ok" as const, + data: { emailAddress: mailbox, historyId: "hist-new" }, + }; + }, + async listHistory() { + return { + outcome: "cursor-invalid" as const, + reason: "Requested entity was not found.", + }; + }, + async getMessage() { + throw new Error("must not fetch messages after cursor invalidation"); + }, + } as unknown as GmailClient; + + const failing = new GmailSyncService( + db, + failingGmail, + tokens, + state, + threads, + ); + + const outcome = await failing.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.reason).toContain("History expired"); + + const refreshed = await db.mailboxSync.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(refreshed.cursor).toBeNull(); + expect(refreshed.status).toBe(GoogleSyncStatus.IDLE); + row = refreshed; + }); +}); diff --git a/apps/api/test/mailbox-auto-create.spec.ts b/apps/api/test/mailbox-auto-create.spec.ts new file mode 100644 index 000000000..595724b1c --- /dev/null +++ b/apps/api/test/mailbox-auto-create.spec.ts @@ -0,0 +1,576 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { + db, + type MailboxSyncModel as MailboxSync, + RecordSource, +} from "@crm/db"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { CompanyDirectoryService } from "../src/companies/company-directory.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { EnrichmentLogService } from "../src/crm/enrichment-log.service"; +import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; +import { + type IncomingMessage, + ThreadWriterService, +} from "../src/mailbox/thread-writer.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "auto-create-spec"; +const workDomain = `auto-${suffix}.test`; +const freeDomain = "gmail.com"; +const userId = `user-auto-${suffix}`; +const mailbox = `rep-auto-${suffix}@example.test`; +const person = `buyer@${workDomain}`; +const newsletter = `news@${workDomain}`; +const noreply = `noreply@${workDomain}`; +const freePerson = `someone@${freeDomain}`; +const rootNewsletter = ``; +const rootOutbound = ``; +const rootInboundReply = ``; +const rootKnown = ``; + +const agent = { + contactCreated: async () => undefined, + companyCreated: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, + companyRequested: async () => undefined, +} as unknown as AgentTriggerService; + +const stamp = new ActivityStampService(db); +const directory = new CompanyDirectoryService(agent); +const log = new EnrichmentLogService(db, stamp); +const match = new MailboxMatchService(db, directory, agent, log); +const threads = new ThreadWriterService(db, match, stamp); + +let gmailOff: MailboxSync; +let gmailOn: MailboxSync; + +function outboundMessage( + rootId: string, + rfcMessageId: string, + to = person, + name: string | null = "A Buyer", +): IncomingMessage { + return { + rfcMessageId, + rootId, + subject: "Pricing", + from: { email: mailbox, name: "Test Rep" }, + recipients: [{ email: to, name, kind: "to" }], + body: "The numbers you asked for.", + sentAt: new Date("2026-03-01T10:00:00Z"), + gmailMessageId: null, + outlookMessageId: null, + outlookWebLink: null, + }; +} + +function inboundMessage( + rootId: string, + rfcMessageId: string, + from = newsletter, + name: string | null = "Weekly Digest", +): IncomingMessage { + return { + rfcMessageId, + rootId, + subject: "This week at Acme", + from: { email: from, name }, + recipients: [{ email: mailbox, name: "Test Rep", kind: "to" }], + body: "Unsubscribe below. Big sale on widgets.", + sentAt: new Date("2026-03-01T11:00:00Z"), + gmailMessageId: null, + outlookMessageId: null, + outlookWebLink: null, + }; +} + +async function matchContext() { + const [internal, suppressedDomains, suppressedEmails] = await Promise.all([ + match.internalIdentity(), + match.suppressedDomains(), + match.suppressedEmails(), + ]); + return { + ourAddresses: internal.addresses, + ourDomains: internal.domains, + suppressedDomains, + suppressedEmails, + }; +} + +async function clean() { + await db.emailThread.deleteMany({ + where: { + rootMessageId: { + in: [rootNewsletter, rootOutbound, rootInboundReply, rootKnown], + }, + }, + }); + await db.contact.deleteMany({ + where: { + email: { + in: [person, newsletter, noreply, freePerson, `second@${workDomain}`], + }, + }, + }); + await db.company.deleteMany({ where: { domain: workDomain } }); + await db.suppressedDomain.deleteMany({ where: { domain: workDomain } }); + await db.mailboxSync.deleteMany({ where: { userId } }); + await db.user.deleteMany({ where: { id: userId } }); +} + +beforeAll(async () => { + await clean(); + + await db.user.create({ + data: { id: userId, name: "Test Rep", email: mailbox }, + }); + gmailOff = await db.mailboxSync.create({ + data: { userId, source: "gmail", autoCreate: false }, + }); + gmailOn = await db.mailboxSync.create({ + data: { + userId, + source: "outlook", + autoCreate: true, + }, + }); +}); + +afterAll(clean); + +describe("auto-create: two-way engagement (Gmail)", () => { + it("does not create a company from an inbound-only newsletter, even with autoCreate on", async () => { + const stored = await threads.store( + gmailOn, + { mailbox, origin: "gmail" }, + inboundMessage( + rootNewsletter, + ``, + newsletter, + ), + await threads.context(), + ); + + expect(stored).toBe(false); + expect(await db.company.count({ where: { domain: workDomain } })).toBe(0); + expect(await db.contact.count({ where: { email: newsletter } })).toBe(0); + expect( + await db.emailThread.count({ where: { rootMessageId: rootNewsletter } }), + ).toBe(0); + }); + + it("creates company and contact with source EMAIL when the rep sends and autoCreate is on", async () => { + const stored = await threads.store( + gmailOn, + { mailbox, origin: "gmail" }, + outboundMessage( + rootOutbound, + ``, + person, + "A Buyer", + ), + await threads.context(), + ); + + expect(stored).toBe(true); + + const company = await db.company.findUnique({ + where: { domain: workDomain }, + select: { id: true, source: true }, + }); + const contact = await db.contact.findUnique({ + where: { email: person }, + select: { id: true, source: true, companyId: true, firstName: true }, + }); + + expect(company?.source).toBe(RecordSource.EMAIL); + expect(contact?.source).toBe(RecordSource.EMAIL); + expect(contact?.companyId).toBe(company?.id ?? null); + expect(contact?.firstName).toBe("A"); + + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: rootOutbound }, + select: { + companyId: true, + contactId: true, + activity: { select: { id: true, type: true } }, + }, + }); + expect(thread?.companyId).toBe(company?.id ?? null); + expect(thread?.contactId).toBe(contact?.id ?? null); + expect(thread?.activity).not.toBeNull(); + }); + + it("does not create a company when autoCreate is off, even if the rep sent the mail", async () => { + const unknownDomain = `off-${suffix}.test`; + const unknownPerson = `lead@${unknownDomain}`; + const root = ``; + + try { + const stored = await threads.store( + gmailOff, + { mailbox, origin: "gmail" }, + outboundMessage( + root, + ``, + unknownPerson, + "Lead Name", + ), + await threads.context(), + ); + + expect(stored).toBe(false); + expect(await db.company.count({ where: { domain: unknownDomain } })).toBe( + 0, + ); + expect(await db.contact.count({ where: { email: unknownPerson } })).toBe( + 0, + ); + } finally { + await db.emailThread.deleteMany({ where: { rootMessageId: root } }); + await db.contact.deleteMany({ where: { email: unknownPerson } }); + await db.company.deleteMany({ where: { domain: unknownDomain } }); + } + }); + + it("still attaches to an existing company when autoCreate is off", async () => { + const knownDomain = `known-${suffix}.test`; + const knownPerson = `known@${knownDomain}`; + const root = rootKnown; + + const company = await db.company.create({ + data: { + name: "Known Co", + domain: knownDomain, + source: RecordSource.MANUAL, + }, + select: { id: true }, + }); + await db.contact.create({ + data: { + firstName: "Known", + lastName: "Buyer", + email: knownPerson, + companyId: company.id, + source: RecordSource.MANUAL, + }, + }); + + try { + const stored = await threads.store( + gmailOff, + { mailbox, origin: "gmail" }, + outboundMessage( + root, + ``, + knownPerson, + "Known Buyer", + ), + await threads.context(), + ); + + expect(stored).toBe(true); + + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: root }, + select: { companyId: true }, + }); + expect(thread?.companyId).toBe(company.id); + + const after = await db.company.findUnique({ + where: { id: company.id }, + select: { source: true }, + }); + expect(after?.source).toBe(RecordSource.MANUAL); + } finally { + await db.emailThread.deleteMany({ where: { rootMessageId: root } }); + await db.contact.deleteMany({ where: { email: knownPerson } }); + await db.company.deleteMany({ where: { id: company.id } }); + } + }); + + it("creates on a later inbound only after the rep has already sent in the thread", async () => { + const domain = `reply-${suffix}.test`; + const lead = `lead@${domain}`; + const root = rootInboundReply; + + try { + const first = await threads.store( + gmailOn, + { mailbox, origin: "gmail" }, + outboundMessage( + root, + ``, + lead, + "Reply Lead", + ), + await threads.context(), + ); + expect(first).toBe(true); + + const company = await db.company.findUnique({ + where: { domain }, + select: { id: true, source: true }, + }); + expect(company?.source).toBe(RecordSource.EMAIL); + + const second = await threads.store( + gmailOn, + { mailbox, origin: "gmail" }, + inboundMessage( + root, + ``, + lead, + "Reply Lead", + ), + await threads.context(), + ); + expect(second).toBe(true); + + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: root }, + select: { messageCount: true, companyId: true }, + }); + expect(thread?.messageCount).toBe(2); + expect(thread?.companyId).toBe(company?.id ?? null); + } finally { + await db.emailThread.deleteMany({ where: { rootMessageId: root } }); + await db.contact.deleteMany({ where: { email: lead } }); + await db.company.deleteMany({ where: { domain } }); + } + }); +}); + +describe("auto-create: match rules and provenance", () => { + it("stamps source CALENDAR when allowCreate is true for a new work domain", async () => { + const domain = `cal-${suffix}.test`; + const email = `attendee@${domain}`; + + try { + const result = await match.resolve( + { + participants: [ + { email: mailbox, name: "Test Rep" }, + { email, name: "Cal Attendee" }, + ], + allowCreate: true, + source: RecordSource.CALENDAR, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.companyId).not.toBeNull(); + expect(result.contactId).not.toBeNull(); + + const company = await db.company.findUnique({ + where: { id: result.companyId! }, + select: { domain: true, source: true }, + }); + const contact = await db.contact.findUnique({ + where: { id: result.contactId! }, + select: { email: true, source: true, firstName: true, lastName: true }, + }); + + expect(company).toEqual({ + domain, + source: RecordSource.CALENDAR, + }); + expect(contact?.email).toBe(email); + expect(contact?.source).toBe(RecordSource.CALENDAR); + expect(contact?.firstName).toBe("Cal"); + expect(contact?.lastName).toBe("Attendee"); + } finally { + await db.contact.deleteMany({ where: { email } }); + await db.company.deleteMany({ where: { domain } }); + } + }); + + it("creates nothing when allowCreate is false for an unknown domain", async () => { + const domain = `deny-${suffix}.test`; + const email = `ghost@${domain}`; + + const result = await match.resolve( + { + participants: [{ email, name: "Ghost" }], + allowCreate: false, + source: RecordSource.CALENDAR, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.companyId).toBeNull(); + expect(result.contactId).toBeNull(); + expect(await db.company.count({ where: { domain } })).toBe(0); + }); + + it("creates nothing for a free-host address even when allowCreate is true", async () => { + const result = await match.resolve( + { + participants: [{ email: freePerson, name: "Free Mail" }], + allowCreate: true, + source: RecordSource.EMAIL, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.external).toEqual([]); + expect(result.companyId).toBeNull(); + expect(result.contactId).toBeNull(); + }); + + it("creates nothing for a no-reply address even when allowCreate is true", async () => { + const result = await match.resolve( + { + participants: [{ email: noreply, name: "No Reply" }], + allowCreate: true, + source: RecordSource.EMAIL, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.external).toEqual([]); + expect(result.companyId).toBeNull(); + expect(await db.company.count({ where: { domain: workDomain } })).toBe(1); + }); + + it("creates nothing for a suppressed domain even when allowCreate is true", async () => { + const domain = `suppressed-${suffix}.test`; + const email = `person@${domain}`; + + await db.suppressedDomain.create({ + data: { domain, reason: "vendor" }, + }); + + try { + const result = await match.resolve( + { + participants: [{ email, name: "Vendor" }], + allowCreate: true, + source: RecordSource.CALENDAR, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.external).toEqual([]); + expect(result.companyId).toBeNull(); + expect(await db.company.count({ where: { domain } })).toBe(0); + } finally { + await db.suppressedDomain.deleteMany({ where: { domain } }); + await db.contact.deleteMany({ where: { email } }); + await db.company.deleteMany({ where: { domain } }); + } + }); + + it("adds a contact on an existing company with provenance when allowCreate is true", async () => { + const domain = `exist-${suffix}.test`; + const email = `newhire@${domain}`; + + const company = await db.company.create({ + data: { + name: "Existing Co", + domain, + source: RecordSource.MANUAL, + }, + select: { id: true }, + }); + + try { + const result = await match.resolve( + { + participants: [{ email, name: "New Hire" }], + allowCreate: true, + source: RecordSource.EMAIL, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.companyId).toBe(company.id); + expect(result.contactId).not.toBeNull(); + + const contact = await db.contact.findUnique({ + where: { id: result.contactId! }, + select: { source: true, companyId: true }, + }); + expect(contact).toEqual({ + source: RecordSource.EMAIL, + companyId: company.id, + }); + + const after = await db.company.findUnique({ + where: { id: company.id }, + select: { source: true }, + }); + expect(after?.source).toBe(RecordSource.MANUAL); + } finally { + await db.contact.deleteMany({ where: { email } }); + await db.company.deleteMany({ where: { id: company.id } }); + } + }); +}); + +describe("auto-create: calendar engagement gate", () => { + it("treats declined-by-us as no create (allowCreate false)", async () => { + const domain = `declined-${suffix}.test`; + const email = `guest@${domain}`; + const declinedByUs = true; + const autoCreate = true; + const allowCreate = autoCreate && !declinedByUs; + + const result = await match.resolve( + { + participants: [{ email, name: "Guest" }], + allowCreate, + source: RecordSource.CALENDAR, + ownerId: userId, + }, + await matchContext(), + ); + + expect(allowCreate).toBe(false); + expect(result.companyId).toBeNull(); + expect(await db.company.count({ where: { domain } })).toBe(0); + }); + + it("allows create when the rep has not declined", async () => { + const domain = `accepted-${suffix}.test`; + const email = `guest@${domain}`; + const declinedByUs = false; + const autoCreate = true; + const allowCreate = autoCreate && !declinedByUs; + + try { + const result = await match.resolve( + { + participants: [{ email, name: "Guest Person" }], + allowCreate, + source: RecordSource.CALENDAR, + ownerId: userId, + }, + await matchContext(), + ); + + expect(allowCreate).toBe(true); + expect(result.companyId).not.toBeNull(); + + const company = await db.company.findUnique({ + where: { id: result.companyId! }, + select: { source: true, domain: true }, + }); + expect(company).toEqual({ + domain, + source: RecordSource.CALENDAR, + }); + } finally { + await db.contact.deleteMany({ where: { email } }); + await db.company.deleteMany({ where: { domain } }); + } + }); +}); diff --git a/apps/api/test/stalled-deals.spec.ts b/apps/api/test/stalled-deals.spec.ts new file mode 100644 index 000000000..878df4117 --- /dev/null +++ b/apps/api/test/stalled-deals.spec.ts @@ -0,0 +1,152 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DealStage, db } from "@crm/db"; +import { STALLED_DEAL } from "@crm/db/stalled-deals"; +import { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { StalledDealsService } from "../src/deals/stalled-deals.service"; + +const suffix = process.env.TEST_RUN_ID ?? crypto.randomUUID().slice(0, 8); +const domain = `stalled-${suffix}.test`; +const ownerId = `stalled-owner-${suffix}`; +const now = new Date("2026-08-12T12:00:00.000Z"); +const staleAt = new Date("2026-07-20T12:00:00.000Z"); +const freshAt = new Date("2026-08-10T12:00:00.000Z"); + +const agent = new AgentTriggerService(db); +const cache = { + store: new Map(), + async get(key: string) { + return this.store.get(key); + }, + async set(key: string, value: unknown) { + this.store.set(key, value); + }, +}; +const service = new StalledDealsService(db, agent, cache as never); + +let companyId = ""; +let stalledDealId = ""; +let freshDealId = ""; +let closedDealId = ""; +let previousBridgeSecret: string | undefined; + +async function clean() { + const deals = [stalledDealId, freshDealId, closedDealId].filter(Boolean); + if (deals.length > 0) { + await db.agentTask.deleteMany({ where: { dealId: { in: deals } } }); + await db.activity.deleteMany({ where: { dealId: { in: deals } } }); + await db.deal.deleteMany({ where: { id: { in: deals } } }); + } + await db.company.deleteMany({ where: { domain } }); + await db.user.deleteMany({ where: { id: ownerId } }); +} + +beforeAll(async () => { + previousBridgeSecret = process.env.AGENT_BRIDGE_SECRET; + delete process.env.AGENT_BRIDGE_SECRET; + await clean(); + + await db.user.create({ + data: { + id: ownerId, + name: "Stalled Owner", + email: `${ownerId}@example.test`, + emailVerified: true, + }, + }); + + const company = await db.company.create({ + data: { name: `Stalled Co ${suffix}`, domain }, + select: { id: true }, + }); + companyId = company.id; + + const stalled = await db.deal.create({ + data: { + name: `Stale Renewal ${suffix}`, + companyId, + ownerId, + stage: DealStage.QUALIFIED_TO_BUY, + createdAt: staleAt, + lastActivityAt: staleAt, + }, + select: { id: true }, + }); + stalledDealId = stalled.id; + + const fresh = await db.deal.create({ + data: { + name: `Fresh Deal ${suffix}`, + companyId, + ownerId, + stage: DealStage.DEMO_BOOKED, + createdAt: freshAt, + lastActivityAt: freshAt, + }, + select: { id: true }, + }); + freshDealId = fresh.id; + + const closed = await db.deal.create({ + data: { + name: `Closed Old ${suffix}`, + companyId, + ownerId, + stage: DealStage.CLOSED_LOST, + closedAt: staleAt, + closedReason: "budget", + createdAt: staleAt, + lastActivityAt: staleAt, + }, + select: { id: true }, + }); + closedDealId = closed.id; +}); + +afterAll(async () => { + await clean(); + if (previousBridgeSecret === undefined) { + delete process.env.AGENT_BRIDGE_SECRET; + } else { + process.env.AGENT_BRIDGE_SECRET = previousBridgeSecret; + } +}); + +describe("stalled-deal detection", () => { + it("enqueues one AgentTask for each open stalled deal", async () => { + const first = await service.sweep(now); + + expect(first.scanned).toBeGreaterThanOrEqual(1); + expect(first.queued).toBe(1); + expect(first.alreadyQueued).toBe(0); + + const tasks = await db.agentTask.findMany({ + where: { + kind: STALLED_DEAL.kind, + dealId: { in: [stalledDealId, freshDealId, closedDealId] }, + finishedAt: null, + }, + select: { dealId: true, reason: true, priority: true }, + }); + + expect(tasks).toHaveLength(1); + expect(tasks[0]?.dealId).toBe(stalledDealId); + expect(tasks[0]?.reason).toContain("Stale Renewal"); + expect(tasks[0]?.reason).toContain("no activity"); + }); + + it("does not double-queue while a task is still open", async () => { + const second = await service.sweep(now); + + expect(second.queued).toBe(0); + expect(second.alreadyQueued).toBe(1); + + const open = await db.agentTask.count({ + where: { + kind: STALLED_DEAL.kind, + dealId: stalledDealId, + finishedAt: null, + }, + }); + expect(open).toBe(1); + }); +}); diff --git a/apps/app/app/(app)/[slug]/settings/research-key.tsx b/apps/app/app/(app)/[slug]/settings/research-key.tsx index 3c5dab1a4..c2ca5590a 100644 --- a/apps/app/app/(app)/[slug]/settings/research-key.tsx +++ b/apps/app/app/(app)/[slug]/settings/research-key.tsx @@ -55,7 +55,9 @@ export function ResearchKey() { Company research Enter your Context API key so our agents can research every company in - the CRM. + the CRM. Other agent sources are deploy-time env vars only: + RAPIDAPI_KEY, PERPLEXITY_API_KEY, BLOB_READ_WRITE_TOKEN, and + AGENT_BRIDGE_SECRET (same value on the app and the agent). diff --git a/apps/app/components/agent-builder/agent-capabilities.tsx b/apps/app/components/agent-builder/agent-capabilities.tsx index 9fe8fa03b..9bf294d69 100644 --- a/apps/app/components/agent-builder/agent-capabilities.tsx +++ b/apps/app/components/agent-builder/agent-capabilities.tsx @@ -29,6 +29,7 @@ export type Resource = { id: string; kind: string; label: string }; export type Capabilities = { readable: boolean; problem: string | null; + lifecycleRole?: "qualify" | "engage" | "advance" | "close" | null; channel: { kind: "channel" | "user"; id: string; label: string } | null; actions: Array<{ type: string; provider: string; summary: string }>; dataScope: { diff --git a/apps/app/components/agent-builder/agent-history.tsx b/apps/app/components/agent-builder/agent-history.tsx index 089032f69..734007dee 100644 --- a/apps/app/components/agent-builder/agent-history.tsx +++ b/apps/app/components/agent-builder/agent-history.tsx @@ -247,7 +247,19 @@ function ExpandedRun({ run }: { run: RunRow }) { value={run.initiatedBy?.name ?? "Eve scheduler"} /> - + + + + + 0 + ? `${run.events.length}/${run.totalEvents}` + : "—" + } + last + />
@@ -434,6 +446,18 @@ function duration(startedAt: string | null, finishedAt: string | null): string { return `${Math.max(0, milliseconds / 1000).toFixed(1)}s`; } +function formatCount(value: number | null | undefined): string { + if (value === null || value === undefined) return "—"; + return value.toLocaleString("en-US"); +} + +function formatCost(value: string | null | undefined): string { + if (value === null || value === undefined || value === "") return "—"; + const amount = Number(value); + if (!Number.isFinite(amount)) return "—"; + return `$${amount.toFixed(4)}`; +} + function eventLabel(type: string, data: unknown): string { const payload = recordOf(data); return textOf(payload.summary, humanStatus(type.replace(/\./g, " "))); diff --git a/apps/app/components/agent-builder/team-agent-detail.tsx b/apps/app/components/agent-builder/team-agent-detail.tsx index e85f0cd39..426633c1a 100644 --- a/apps/app/components/agent-builder/team-agent-detail.tsx +++ b/apps/app/components/agent-builder/team-agent-detail.tsx @@ -209,6 +209,14 @@ export function TeamAgentDetail({ enabledTriggers.length === 1 ? enabledTriggers[0]?.nextRunAt : null; const triggerSummary = enabledTriggers.map((trigger) => trigger.name).join(" · ") || "Manual only"; + const detailCapabilities = ( + data as unknown as { capabilities?: Capabilities } + ).capabilities; + const lifecycleRole = + detailCapabilities?.lifecycleRole ?? + (isLifecycleRoleText(reviewManifest.lifecycleRole) + ? reviewManifest.lifecycleRole + : null); return ( @@ -216,6 +224,11 @@ export function TeamAgentDetail({ {displayedName} + {lifecycleRole ? ( + + {lifecycleRole} + + ) : null} {displayedDescription} @@ -587,6 +600,17 @@ function textOf(value: unknown, fallback: string): string { return typeof value === "string" && value.trim() ? value : fallback; } +function isLifecycleRoleText( + value: unknown, +): value is "qualify" | "engage" | "advance" | "close" { + return ( + value === "qualify" || + value === "engage" || + value === "advance" || + value === "close" + ); +} + function formatDate(value: string): string { return DATE_FORMATTER.format(new Date(value)); } diff --git a/apps/app/components/agent-builder/team-agents-index.tsx b/apps/app/components/agent-builder/team-agents-index.tsx index 4d4b5bf55..af7e01e48 100644 --- a/apps/app/components/agent-builder/team-agents-index.tsx +++ b/apps/app/components/agent-builder/team-agents-index.tsx @@ -10,6 +10,7 @@ import type { RouterOutputs } from "@/lib/trpc/types"; import { useWorkspaceUrl } from "@/lib/use-workspace-url"; type Agents = RouterOutputs["agents"]["list"]; +type Fleet = RouterOutputs["agents"]["observability"]; export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { const trpc = useTRPC(); @@ -18,10 +19,12 @@ export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { ...trpc.agents.list.queryOptions(), initialData: initialAgents, }); + const fleet = useQuery(trpc.agents.observability.queryOptions()); const rows = agents.data ?? initialAgents; return ( <> + {fleet.data ? : null} {rows.length ? (
{rows.map((agent) => ( @@ -39,6 +42,11 @@ export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { {agent.name} + {agent.lifecycleRole ? ( + + {agent.lifecycleRole} + + ) : null} {agent.status.toLowerCase()} @@ -79,3 +87,84 @@ export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { ); } + +function FleetHealth({ fleet }: { fleet: Fleet }) { + const open = Object.values(fleet.openRunsByStatus).reduce( + (sum: number, count: number) => sum + count, + 0, + ); + + return ( +
+
+

Fleet health (24h)

+

+ Run status, action quality, and token cost for team agents. Aggregate + counts only. +

+
+
+ + + 0} + /> + 0} + /> + 0 + ? ` (${fleet.quality.cancelAfterAction} after action)` + : "" + }`} + /> + + + +
+ {Object.keys(fleet.runsByLifecycleRole).length > 0 ? ( +
+ Roles ·{" "} + {Object.entries(fleet.runsByLifecycleRole) + .map(([role, count]) => `${role} ${count}`) + .join(" · ")} +
+ ) : null} +
+ ); +} + +function Stat({ + label, + value, + warn = false, +}: { + label: string; + value: string; + warn?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/apps/app/components/crm/record-sheet/deal-sheet.tsx b/apps/app/components/crm/record-sheet/deal-sheet.tsx index 78f37749b..74e71d948 100644 --- a/apps/app/components/crm/record-sheet/deal-sheet.tsx +++ b/apps/app/components/crm/record-sheet/deal-sheet.tsx @@ -221,6 +221,13 @@ export function DealSheet({ dealId }: { dealId: string }) { )} + + {deal.dealScore === null ? ( + + ) : ( + {deal.dealScore} + )} + {deal.expectedCloseDate ? ( @@ -368,11 +375,90 @@ function DealOverview({ deal }: { deal: Deal }) { /> + + save({ forecastContextManual })} + /> + ); } +function DealScoreCard({ deal }: { deal: Deal }) { + return ( + + {deal.dealScore === null ? ( +

+ No score yet. The agent scores open deals after a stage change and on + the nightly pass. +

+ ) : ( + + + {deal.dealScore} + / 100 + + {deal.dealScoredAt ? ( + + + + ) : null} + {deal.dealScoreSummary ? ( + + {deal.dealScoreSummary} + + ) : null} + + )} +
+ ); +} + +function ForecastContextCard({ + deal, + saving, + onSave, +}: { + deal: Deal; + saving: boolean; + onSave: (value: string | null) => void; +}) { + const manual = deal.forecastContextManual?.trim() ?? ""; + const ai = deal.forecastContext?.trim() ?? ""; + + return ( + + {ai ? ( +
+

+ From the agent +

+

{ai}

+
+ ) : ( +

+ No agent forecast yet. +

+ )} + {manual ? ( +

+ Your note wins when set. Clear it to show the agent text again. +

+ ) : null} + onSave(next === "" ? null : next)} + /> +
+ ); +} + function WhereItStands({ deal }: { deal: Deal }) { const openRecord = useOpenRecord(); diff --git a/docs/agent.md b/docs/agent.md index 89eb53774..012c169af 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -49,16 +49,27 @@ agent and the API both need it. | | Kinds | How | Per tick | | --- | --- | --- | --- | -| **Visible** | `brand`, `portrait` | Directly — no `receive`, no model | 60, six at a time | +| **Direct** | `brand`, `portrait`, `slack-people-match`, `slack-channel-join`, `agent-event` | Directly — no `receive`, no model | 60, six at a time | | **Research** | everything else | One eve session per row | 12 | -**Neither visible kind has anything to decide**, and through a session they queued -behind sixty LLM runs for 25 minutes (`test/lanes.integration.spec.ts`). **The row says -what the work is; the lane only says whether it needs a conversation.** - -**Priority**: `brand` 900 · `portrait` 800 · `workspace` 500 · `requested` 300 · -`meeting` 200 · `identify` 100 · `sweep` 50 · `companyProfile` 40 · `recheck` 0. The -top two are what a rep reads *before* deciding what to open. +**None of the direct kinds have anything to decide**, and through a session they +queued behind sixty LLM runs for 25 minutes (`test/lanes.integration.spec.ts`). +**The row says what the work is; the lane only says whether it needs a +conversation.** Company brand and contact portrait are what a rep reads *before* +opening a record. The Slack and event kinds are ops work on the same fast lane so +they never wait behind research sessions either. + +**Priority**: `slackJoin` 950 · `brand` 900 · `portrait` 800 · `event` 700 · +`workspace` 500 · `requested` 300 · `meeting` 200 · `slackPeople` 150 · +`stalledDeal` 120 · `identify` 100 · `dealScore` 80 · `sweep` 50 · +`companyProfile` 40 · `fieldBackfill` 20 · `recheck` 0. Brand stays at 900 so +company visual identity claims ahead of every research kind. + +**`deal-score`** is a research-lane kind. Nest enqueues it on stage change and on a +nightly open-deal sweep. The session reads the deal timeline and calls +`write_deal_intelligence` once. That tool writes `dealScore`, `dealScoreSummary`, +`dealScoredAt`, and `forecastContext`. It never overwrites `forecastContextManual`. +The sheet shows the manual forecast when set. **`claimDue` sorts what it claims** — Postgres does not order `UPDATE … RETURNING` by its sub-select's `ORDER BY`. @@ -189,6 +200,11 @@ states it in the session instructions, and gives tools a shared "not configured, retrying will not help" result — **checked before the research budget is charged**. A missing key removes a place to look. **Never an error, never throws.** +It tracks `RAPIDAPI_KEY`, `PERPLEXITY_API_KEY`, `BLOB_READ_WRITE_TOKEN`, +`AGENT_BRIDGE_SECRET`, and the Context key from Settings → General. +`FULL_AGENTIC_CHECKLIST` / `enableChecklistMarkdown()` is the captain-facing enable +list (env names only; no secret values). + **`capabilities()` is async** because the Context key is a row; `capabilitiesFrom()`/`markdownFor()` are the pure halves. `contextDevKey()` is the only resolver, and `lib/context-dev.ts` memoises its client on the key string. @@ -311,6 +327,13 @@ delegation paths for custom agents. team. Scheduled runner sessions use task mode and therefore cannot pause for a per-action approval; the deployed permission and idempotent runtime checks are the boundary. +- **Lifecycle specialists are Deploy-gated team agents.** Optional manifest field + `lifecycleRole` is one of `qualify` | `engage` | `advance` | `close`. Templates: + `lifecycle-qualify.ts`, `lifecycle-engage.ts`, `lifecycle-advance.ts`, + `lifecycle-close.ts` under `apps/agent/agent/lib/`. All are recommend-only: CRM + note/task plus `run.summary`, no send tools. Engage never sends email or SMS. + Advance never mutates deal stage. Close never reopens deals or writes finance + fields. Saving never makes a version LIVE. - **Approved instructions are system context.** The runner resolves the pinned version instructions at `session.started`, then calls `inspect_run` for the manifest and current run state. Every runner tool also checks the `team-agent` purpose and @@ -612,13 +635,14 @@ bun run --filter=agent dispatch ``` It drains **both lanes**, exactly as the cron does: up to `VISIBLE_BATCH` (60) -`brand` and `portrait` rows six at a time, handled in the process with no session -at all, and `RESEARCH_BATCH` (12) research rows, one session each. So the -`sessionIds` it prints are the research rows only — a run that resolved forty -logos prints an empty list and was not idle. Either way it spends real credits, a -vendor call per visible row and a model session per research one; that is the -point of it, and the reason it is a command you run rather than a ticker somebody -leaves on. Watch the agent pane; the session ids it returns are also streamable at +direct rows (`brand`, `portrait`, and the other `DIRECT_KINDS`) six at a time, +handled in the process with no session at all, and `RESEARCH_BATCH` (12) +research rows, one session each. So the `sessionIds` it prints are the research +rows only — a run that resolved forty logos prints an empty list and was not +idle. Either way it spends real credits, a vendor call per brand row and a model +session per research one; that is the point of it, and the reason it is a +command you run rather than a ticker somebody leaves on. Watch the agent pane; +the session ids it returns are also streamable at `GET /eve/v1/session/:id/stream`. `eve start` on a built app *does* run the schedule, and so does Vercel, where diff --git a/docs/environment.md b/docs/environment.md index aca350183..efebdf7b8 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -111,7 +111,7 @@ single place that knows what is set. | Variable | What it adds | | --- | --- | -| `PERPLEXITY_API_KEY` | Open-web research with citations; finds a LinkedIn slug | +| `PERPLEXITY_API_KEY` | Open-web research with citations (not identity slug search) | | `RAPIDAPI_KEY` | LinkedIn profiles via LinkDAPI | | `GITHUB_TOKEN` | Raises the GitHub rate limit from 60/hour | | `BLOB_READ_WRITE_TOKEN` | Mirrors logos and photos into Blob | @@ -122,6 +122,23 @@ single place that knows what is set. because the API and the seed write pictures too. The Next.js app is deliberately excluded — recognising our URL for the image optimizer needs no token. +### Full agentic mode checklist + +Set these for full research + rep Agent panel on production (names only; never +commit values). Source of truth: `FULL_AGENTIC_CHECKLIST` in +`apps/agent/agent/lib/capabilities.ts`. + +| Source | Kind | Unlocks | +| --- | --- | --- | +| `RAPIDAPI_KEY` | env | LinkedIn identity | +| `PERPLEXITY_API_KEY` | env | Web research / LinkedIn slug search | +| `BLOB_READ_WRITE_TOKEN` | env | Stored logos and photos | +| `AGENT_BRIDGE_SECRET` | env | Agent tab + dispatch poke (same value on app and agent) | +| Settings → General | setting | Context company brand data (not an env var) | + +Also needed for the model outside Vercel OIDC: `AI_GATEWAY_API_KEY`. Optional +rate-limit help for GitHub matching: `GITHUB_TOKEN`. + ### The Context key is asked for, not configured **`CONTEXT_DEV_API_KEY` is not a variable here and must not become one.** The key lives @@ -130,10 +147,10 @@ General — an admin who cannot redeploy cannot set a variable. - **An install that had the variable is asked again**: no migration, no fallback, and **the gate cannot be dismissed**. -- **Nothing is lost while waiting.** A keyless `brand` task settles `SKIPPED` *before* - anything marks the row `RUNNING`, and `settle` only overwrites `RUNNING` — so the - company stays `PENDING`, which the sweep re-queues - (`test/keyless-brand.integration.spec.ts`). +- **Nothing is lost while waiting.** A keyless `brand` task returns without writing + enrichment status (it never marks the row `RUNNING`). On the enrichment path, + `settle` only overwrites `RUNNING`. Either way the company stays `PENDING`, + which the sweep re-queues (`test/keyless-brand.integration.spec.ts`). - **Saving the key runs the company sweep immediately** (fire-and-forget). - **`readContextDevKey` (`@crm/db/settings`) is the only reader**, read live with no cache. An unreadable database is a capability that is off, not an exception. diff --git a/docs/plan/gmail-calendar-plan.md b/docs/plan/gmail-calendar-plan.md index ae6fe4dda..ee5399663 100644 --- a/docs/plan/gmail-calendar-plan.md +++ b/docs/plan/gmail-calendar-plan.md @@ -819,3 +819,97 @@ with `class-validator` decorators, and to `docs/environment.md`. an account that will not grant the scopes cannot use the CRM anyway. Revisit only if someone needs "keep my login, stop reading my mail", which the policy currently says is not a state we support. + +--- + +## 16. Status and handoff (calendar + Gmail + auto-create) + +### Shipped (phases 0–3 + phase 4 rules) + +Phases **0–3** are in the tree (calendar + Gmail threads). Phase **4** auto-create +rules and acceptance tests ship in the same wave; Gmail auto-create stays off by default. + +| Concern | Where | +| --- | --- | +| Scopes + offline access | `packages/auth/src/scopes.ts`, `packages/auth/src/auth.ts` | +| App shell gate | `apps/app/lib/session.ts` → `requireMailboxAccess()`, `/grant-access` | +| `MailboxSync` + event/email tables + `Activity` FKs | `packages/db/prisma/schema.prisma` | +| Calendar list client + pure helpers | `apps/api/src/google/calendar.client.ts` | +| Calendar forward-only sync, relevance, projection | `apps/api/src/google/calendar-sync.service.ts` | +| Gmail history client + MIME parse | `apps/api/src/google/gmail.client.ts`, `gmail-mime.ts` | +| Gmail forward-only sync (`historyId`) | `apps/api/src/google/gmail-sync.service.ts` | +| Thread write + EMAIL activity projection | `apps/api/src/mailbox/thread-writer.service.ts` | +| Match (contact → company; create only if allowed) | `apps/api/src/mailbox/mailbox-match.service.ts` | +| Cron tick + dispatch | `apps/api/src/sync/mailbox-sync.service.ts`, `sync.controller.ts` | +| Expand path with full bodies | `google.thread` → `ConversationService.thread` | +| Timeline filters `meetings` / `email` + accordion UI | `activities.contracts.ts`, `timeline-search-params.ts`, `email-thread-entry.tsx` | +| Connection / status / purge | `apps/api/src/google/google-connection.service.ts` | +| Acceptance tests | `apps/api/test/calendar-sync.spec.ts`, `calendar-client.spec.ts`, `gmail-sync.spec.ts` | + +Rules already enforced for calendar: + +- Identity is `(iCalUid, originalStartTime)` unique. +- Only events that resolve to a tracked company/contact are stored when + `autoCreate` is off (phase-1 relevance). +- Cancellations delete the `CalendarEvent` and cascade the projected `Activity`. +- Cursor invalidation (410) clears the cursor and resumes from `now` (no backfill). +- `timeMin` is now; horizon is 180 days. + +Rules already enforced for Gmail: + +- Identity is RFC 822 `Message-ID` (normalised) for messages and the root of + `References` / `In-Reply-To` / own id for threads — not Gmail `threadId`. +- Cross-mailbox copies of one conversation produce one `EmailThread` and one + projected `Activity`. +- Only threads that resolve to a tracked company/contact are stored when + `autoCreate` is off (Gmail auto-create stays off in this wave). +- Timeline list payloads carry a snippet on `Activity.body` and thread summary + fields only. Full message bodies load on expand via `google.thread`. +- First sight of a mailbox stores the current `historyId` and imports nothing. +- History 404 clears the cursor and resumes from now (no backfill). + +### Phase 4 auto-create (shipped on existing substrate) + +### Shipped (phase 4 rules on the existing substrate) + +Phase **4** reuses the Gmail and Calendar sync path. Nest matches and creates +rows only. Intelligence still lives in `apps/agent` via `company.created` / +`contact.created` tasks. No generic agent-copy and no auto-send. + +| Concern | Where | +| --- | --- | +| Two-way Gmail gate | `ThreadWriterService.store` — `allowCreate: row.autoCreate && repliedTo` | +| Calendar gate | `CalendarSyncService.apply` — `allowCreate: row.autoCreate && !declinedByUs` | +| Create + provenance | `MailboxMatchService` stamps `RecordSource.EMAIL` / `CALENDAR` on company and contact | +| Defaults | `GoogleConnectionService.onConnected` — calendar `autoCreate: true`, gmail `false` | +| Toggles | `google.setAutoCreate` / settings connection card | +| Undo | companies/contacts `source` filter + `bulkDelete`; `google.suppressDomain` (+ optional purge) | +| Noise filters | free hosts, no-reply local parts, `SuppressedDomain`, own Workspace domain | +| Acceptance tests | `apps/api/test/mailbox-auto-create.spec.ts` | + +Rules locked by tests: + +- Inbound-only (newsletter) never creates, even when Gmail auto-create is on. +- Rep-sent mail with auto-create on creates company + contact with `source = EMAIL`. +- Auto-create off: unknown domains create nothing; known companies still attach. +- Calendar allowCreate stamps `source = CALENDAR`; declined-by-us does not create. +- Free hosts, no-reply, and suppressed domains create nothing. + +### Ops note (not a code gate) + +Plan §12 still prefers a week of real-mailbox soak before flipping Gmail +auto-create on by default. The product default stays **calendar on, gmail off**. + +### Out of scope here + +- Phase 5 Pub/Sub real-time (`users.watch`). +- Sending mail from the CRM. +- Lifecycle specialist agents (qualify/engage) — separate Deploy-gated work. +- Changing the Gmail default to on. + +### Done when (this lane) + +A meeting with an unknown work domain creates a company and contact tagged +`CALENDAR`. A newsletter creates nothing. Gmail creates only on two-way +engagement when the toggle is on. Provenance filters and suppress-domain undo +remain available. The suite in `mailbox-auto-create.spec.ts` is green. diff --git a/docs/telemetry.md b/docs/telemetry.md index b89a9cdff..416652d7a 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -121,6 +121,29 @@ has been minted — never the id itself. No key, value or last-four is sent. | `recheck_interval_days` | Their intervals, in day bands | | `agent_conversations` | How many `AgentConversation` rows exist | +#### Team agents (Deploy-gated fleet) + +Aggregate counts from `AgentRun` and `AgentAction` in the same 24h window. Never a summary, +prompt, action body, Slack text, or email draft. + +| Property | What it is | +| --- | --- | +| `team_runs_by_status` | Counts by `AgentRunStatus` | +| `team_runs_by_trigger` | Counts by `AgentTriggerType` (`MANUAL` / `SCHEDULE` / `EVENT` / `WEBHOOK`) | +| `team_runs_by_lifecycle_role` | Counts by manifest `lifecycleRole` (`qualify` / `engage` / `advance` / `close`), or `none` when untagged. Unknown values are `other` | +| `team_actions_by_type` | Counts by action type allowlist (`crm.activity.create`, `run.summary`, `slack.message.post`, `crm.outreach.recommend`). Anything else is `other` | +| `team_actions_by_status` | Counts by `AgentActionStatus` | +| `team_runs_cancelled` | Runs cancelled or with `cancelRequestedAt` set | +| `team_runs_cancel_after_action` | Of those, runs that already completed at least one ledgered action (side effects stay) | +| `team_dependency_failures` | Runs whose `errorCode` is `DEPENDENCY_UNAVAILABLE` | +| `team_input_tokens` | Sum of `AgentRun.inputTokens` | +| `team_output_tokens` | Sum of `AgentRun.outputTokens` | +| `team_cost_usd` | Sum of `AgentRun.costUsd` (two decimal places) | + +In-product fleet health for the same aggregates lives on `agents.observability` (tRPC) and the +team agents index. Per-run cost, tokens, and session event counts render in the run history +drawer. Neither surface sends free text about a named person. + Tool names are matched against the authored tools in `apps/agent/agent/tools/` and eve's own builtins. Anything else is counted as `other`. Task kinds are matched against `TASK_KINDS`. @@ -291,6 +314,8 @@ default and the page has no field to type in. - `EmailThread` and `EmailMessage` subjects or bodies, `CalendarEvent` titles, `CalendarAttendee` rows - `Deal` names and amounts. Stage distribution is fine; amounts are not. - `AgentEvent.data`, `AgentConversation` content, prompts, completions, reasoning traces +- `AgentRun.summary`, `AgentRun.input`, `AgentRun.result`, `AgentAction.summary`, action + metadata, or outreach draft bodies — team-agent telemetry is counts, tokens and cost only - `ALLOWED_SIGN_IN`, `AppSetting.contextDevApiKey`, any key, secret, token or connection string - `SuppressedDomain` and `SuppressedContact` values — counts only - **IP address.** Set `$ip: null` and disable geoip. n8n collects IP and has to caveat their diff --git a/packages/db/package.json b/packages/db/package.json index 9ef448183..1e1beb60d 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -11,6 +11,7 @@ "./client": "./src/client.ts", "./currency": "./src/currency.ts", "./crm-events": "./src/crm-events.ts", + "./deal-score": "./src/deal-score.ts", "./deal-stage": "./src/deal-stage.ts", "./enums": "./src/generated/prisma/enums.ts", "./favicon": "./src/favicon.ts", @@ -22,6 +23,7 @@ "./safe-fetch": "./src/safe-fetch.ts", "./settings": "./src/settings.ts", "./slack-inventory": "./src/slack-inventory.ts", + "./stalled-deals": "./src/stalled-deals.ts", "./tracking": "./src/tracking.ts", "./workspace": "./src/workspace.ts" }, diff --git a/packages/db/prisma/migrations/20260812010000_deal_score_forecast/migration.sql b/packages/db/prisma/migrations/20260812010000_deal_score_forecast/migration.sql new file mode 100644 index 000000000..ea503bd5a --- /dev/null +++ b/packages/db/prisma/migrations/20260812010000_deal_score_forecast/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "deal" ADD COLUMN "dealScore" INTEGER, +ADD COLUMN "dealScoreSummary" TEXT, +ADD COLUMN "dealScoredAt" TIMESTAMP(3), +ADD COLUMN "forecastContext" TEXT, +ADD COLUMN "forecastContextManual" TEXT; + +-- CreateIndex +CREATE INDEX "deal_dealScoredAt_idx" ON "deal"("dealScoredAt"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 28a49c397..f9eb7bf53 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -927,6 +927,12 @@ model Deal { lastActivityAt DateTime? + dealScore Int? + dealScoreSummary String? + dealScoredAt DateTime? + forecastContext String? + forecastContextManual String? + contacts DealContact[] activities Activity[] fieldValues FieldValue[] @@ -938,6 +944,7 @@ model Deal { @@index([stage]) @@index([expectedCloseDate]) @@index([lastActivityAt]) + @@index([dealScoredAt]) @@index([baseAmount]) @@index([currency]) @@map("deal") diff --git a/packages/db/src/agent-tasks.ts b/packages/db/src/agent-tasks.ts index 33f60eb17..2b9cbdf12 100644 --- a/packages/db/src/agent-tasks.ts +++ b/packages/db/src/agent-tasks.ts @@ -11,6 +11,8 @@ export const TASK_KINDS = [ "slack-people-match", "slack-channel-join", "agent-event", + "stalled-deal", + "deal-score", ] as const; export type TaskKind = (typeof TASK_KINDS)[number]; @@ -21,6 +23,7 @@ export const DIRECT_KINDS = [ "slack-people-match", "slack-channel-join", "agent-event", + "stalled-deal", ] as const; export type DirectKind = (typeof DIRECT_KINDS)[number]; @@ -47,4 +50,10 @@ export const PRIORITY = { slackPeople: 150, slackJoin: 950, event: 700, + stalledDeal: 120, + dealScore: 80, } as const; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export const PORTRAIT_STAND_DOWN_MS = 30 * DAY_MS; diff --git a/packages/db/src/deal-score.ts b/packages/db/src/deal-score.ts new file mode 100644 index 000000000..f2dce7d7a --- /dev/null +++ b/packages/db/src/deal-score.ts @@ -0,0 +1,58 @@ +const DAY_MS = 86_400_000; + +export const DEAL_SCORE = { + kind: "deal-score", + min: 0, + max: 100, + summaryMax: 800, + forecastMax: 2_000, + maxPerRun: 100, + rescoreAfterDays: 1, +} as const; + +export function clampDealScore(score: number): number { + if (!Number.isFinite(score)) return DEAL_SCORE.min; + return Math.min(DEAL_SCORE.max, Math.max(DEAL_SCORE.min, Math.round(score))); +} + +export function isValidDealScore(score: number): boolean { + return ( + Number.isInteger(score) && + score >= DEAL_SCORE.min && + score <= DEAL_SCORE.max + ); +} + +export function blankToNull(value: string | null | undefined): string | null { + if (value === null || value === undefined) return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function effectiveForecastContext( + forecastContext: string | null | undefined, + forecastContextManual: string | null | undefined, +): string | null { + const manual = blankToNull(forecastContextManual); + if (manual !== null) return manual; + return blankToNull(forecastContext); +} + +export function scoreRescoreCutoff( + now: Date, + rescoreAfterDays: number = DEAL_SCORE.rescoreAfterDays, +): Date { + return new Date(now.getTime() - Math.max(rescoreAfterDays, 0) * DAY_MS); +} + +export function needsDealScore(input: { + dealScoredAt: Date | null; + now: Date; + rescoreAfterDays?: number; +}): boolean { + if (!input.dealScoredAt) return true; + return ( + input.dealScoredAt.getTime() <= + scoreRescoreCutoff(input.now, input.rescoreAfterDays).getTime() + ); +} diff --git a/packages/db/src/stalled-deals.ts b/packages/db/src/stalled-deals.ts new file mode 100644 index 000000000..9b83555d9 --- /dev/null +++ b/packages/db/src/stalled-deals.ts @@ -0,0 +1,57 @@ +const DAY_MS = 86_400_000; + +export const STALLED_DEAL = { + kind: "stalled-deal", + inactiveDays: 14, + maxPerRun: 100, + source: "stalled-deal", + subjectPrefix: "Re-engage:", +} as const; + +export type StalledDealInput = { + lastActivityAt: Date | null; + createdAt: Date; + now: Date; + inactiveDays?: number; +}; + +export function stallCutoff( + now: Date, + inactiveDays: number = STALLED_DEAL.inactiveDays, +): Date { + return new Date(now.getTime() - Math.max(inactiveDays, 0) * DAY_MS); +} + +export function activityAnchor( + lastActivityAt: Date | null, + createdAt: Date, +): Date { + return lastActivityAt ?? createdAt; +} + +export function daysInactive(input: StalledDealInput): number { + const anchor = activityAnchor(input.lastActivityAt, input.createdAt); + return Math.max( + 0, + Math.floor((input.now.getTime() - anchor.getTime()) / DAY_MS), + ); +} + +export function isStalledDeal(input: StalledDealInput): boolean { + const inactiveDays = input.inactiveDays ?? STALLED_DEAL.inactiveDays; + const anchor = activityAnchor(input.lastActivityAt, input.createdAt); + return anchor.getTime() <= stallCutoff(input.now, inactiveDays).getTime(); +} + +export function stallReason(dealName: string, days: number): string { + const label = dealName.trim() || "Untitled deal"; + if (days <= 0) { + return `${label} has no recent activity.`; + } + return `${label} has had no activity for ${days} day${days === 1 ? "" : "s"}.`; +} + +export function stallTaskSubject(dealName: string): string { + const label = dealName.trim() || "Untitled deal"; + return `${STALLED_DEAL.subjectPrefix} ${label}`; +} diff --git a/packages/db/test/agent-tasks.spec.ts b/packages/db/test/agent-tasks.spec.ts new file mode 100644 index 000000000..7bfe3a09c --- /dev/null +++ b/packages/db/test/agent-tasks.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import { + DIRECT_KINDS, + isDirectKind, + PRIORITY, + TASK_KINDS, +} from "../src/agent-tasks"; + +describe("brand visual identity lane", () => { + it("keeps brand on the direct lane at priority 900", () => { + expect(TASK_KINDS).toContain("brand"); + expect(DIRECT_KINDS).toContain("brand"); + expect(isDirectKind("brand")).toBe(true); + expect(PRIORITY.brand).toBe(900); + }); + + it("orders brand ahead of research work a rep must open to see", () => { + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.portrait); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.workspace); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.requested); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.meeting); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.identify); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.sweep); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.companyProfile); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.recheck); + }); + + it("keeps research kinds off the direct lane", () => { + expect(isDirectKind("company-profile")).toBe(false); + expect(isDirectKind("identify")).toBe(false); + expect(isDirectKind("workspace-profile")).toBe(false); + expect(isDirectKind("meeting-prep")).toBe(false); + expect(isDirectKind("recheck")).toBe(false); + }); +}); diff --git a/packages/db/test/deal-score.spec.ts b/packages/db/test/deal-score.spec.ts new file mode 100644 index 000000000..44b9067f9 --- /dev/null +++ b/packages/db/test/deal-score.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test"; +import { + blankToNull, + clampDealScore, + DEAL_SCORE, + effectiveForecastContext, + isValidDealScore, + needsDealScore, + scoreRescoreCutoff, +} from "../src/deal-score"; + +describe("deal score helpers", () => { + it("clamps scores to 0–100 integers", () => { + expect(clampDealScore(-4)).toBe(0); + expect(clampDealScore(140.7)).toBe(100); + expect(clampDealScore(72.4)).toBe(72); + expect(isValidDealScore(0)).toBe(true); + expect(isValidDealScore(100)).toBe(true); + expect(isValidDealScore(72.5)).toBe(false); + expect(isValidDealScore(-1)).toBe(false); + }); + + it("prefers manual forecast context when set", () => { + expect( + effectiveForecastContext("AI says close next month", "Rep: wait for CFO"), + ).toBe("Rep: wait for CFO"); + expect(effectiveForecastContext("AI summary", null)).toBe("AI summary"); + expect(effectiveForecastContext("AI summary", " ")).toBe("AI summary"); + expect(effectiveForecastContext(null, null)).toBe(null); + expect(blankToNull(" note ")).toBe("note"); + expect(blankToNull("")).toBe(null); + }); + + it("flags deals that need a rescore after the cutoff", () => { + const now = new Date("2026-08-12T12:00:00.000Z"); + expect(needsDealScore({ dealScoredAt: null, now })).toBe(true); + expect( + needsDealScore({ + dealScoredAt: new Date("2026-08-12T08:00:00.000Z"), + now, + }), + ).toBe(false); + expect( + needsDealScore({ + dealScoredAt: new Date("2026-08-10T12:00:00.000Z"), + now, + }), + ).toBe(true); + expect(scoreRescoreCutoff(now).toISOString()).toBe( + new Date( + now.getTime() - DEAL_SCORE.rescoreAfterDays * 86_400_000, + ).toISOString(), + ); + }); +}); diff --git a/packages/db/test/stalled-deals.spec.ts b/packages/db/test/stalled-deals.spec.ts new file mode 100644 index 000000000..e15c11c92 --- /dev/null +++ b/packages/db/test/stalled-deals.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import { + activityAnchor, + daysInactive, + isStalledDeal, + STALLED_DEAL, + stallCutoff, + stallReason, + stallTaskSubject, +} from "../src/stalled-deals"; + +const NOW = new Date("2026-08-12T12:00:00.000Z"); + +describe("stallCutoff", () => { + it("subtracts whole days from now", () => { + expect(stallCutoff(NOW, 14).toISOString()).toBe("2026-07-29T12:00:00.000Z"); + }); + + it("treats a negative window as zero", () => { + expect(stallCutoff(NOW, -3).getTime()).toBe(NOW.getTime()); + }); +}); + +describe("activityAnchor", () => { + it("prefers lastActivityAt when present", () => { + const last = new Date("2026-08-01T00:00:00.000Z"); + const created = new Date("2026-07-01T00:00:00.000Z"); + expect(activityAnchor(last, created)).toBe(last); + }); + + it("falls back to createdAt when never active", () => { + const created = new Date("2026-07-01T00:00:00.000Z"); + expect(activityAnchor(null, created)).toBe(created); + }); +}); + +describe("isStalledDeal", () => { + it("flags an open deal past the inactive window", () => { + expect( + isStalledDeal({ + lastActivityAt: new Date("2026-07-20T12:00:00.000Z"), + createdAt: new Date("2026-06-01T00:00:00.000Z"), + now: NOW, + inactiveDays: 14, + }), + ).toBe(true); + }); + + it("keeps a recently active deal open", () => { + expect( + isStalledDeal({ + lastActivityAt: new Date("2026-08-10T12:00:00.000Z"), + createdAt: new Date("2026-06-01T00:00:00.000Z"), + now: NOW, + inactiveDays: 14, + }), + ).toBe(false); + }); + + it("uses createdAt when the deal has no activity stamp", () => { + expect( + isStalledDeal({ + lastActivityAt: null, + createdAt: new Date("2026-07-01T00:00:00.000Z"), + now: NOW, + inactiveDays: 14, + }), + ).toBe(true); + + expect( + isStalledDeal({ + lastActivityAt: null, + createdAt: new Date("2026-08-10T00:00:00.000Z"), + now: NOW, + inactiveDays: 14, + }), + ).toBe(false); + }); + + it("defaults to STALLED_DEAL.inactiveDays", () => { + expect(STALLED_DEAL.inactiveDays).toBe(14); + expect( + isStalledDeal({ + lastActivityAt: stallCutoff(NOW), + createdAt: new Date("2026-01-01T00:00:00.000Z"), + now: NOW, + }), + ).toBe(true); + }); +}); + +describe("daysInactive and reasons", () => { + it("counts whole days from the activity anchor", () => { + expect( + daysInactive({ + lastActivityAt: new Date("2026-07-29T12:00:00.000Z"), + createdAt: new Date("2026-01-01T00:00:00.000Z"), + now: NOW, + }), + ).toBe(14); + }); + + it("writes a stable reason and subject", () => { + expect(stallReason("Acme renewal", 14)).toBe( + "Acme renewal has had no activity for 14 days.", + ); + expect(stallReason("Acme renewal", 1)).toBe( + "Acme renewal has had no activity for 1 day.", + ); + expect(stallTaskSubject("Acme renewal")).toBe("Re-engage: Acme renewal"); + expect(stallTaskSubject(" ")).toBe("Re-engage: Untitled deal"); + }); +}); diff --git a/packages/telemetry/src/allowlist.ts b/packages/telemetry/src/allowlist.ts index 1c6d7492b..eafe12c1f 100644 --- a/packages/telemetry/src/allowlist.ts +++ b/packages/telemetry/src/allowlist.ts @@ -44,6 +44,18 @@ export const ALLOWED_PROPERTIES = [ "sandbox_used", "agent_conversations", + "team_runs_by_status", + "team_runs_by_trigger", + "team_runs_by_lifecycle_role", + "team_actions_by_type", + "team_actions_by_status", + "team_runs_cancelled", + "team_runs_cancel_after_action", + "team_dependency_failures", + "team_input_tokens", + "team_output_tokens", + "team_cost_usd", + "facts_by_status", "facts_by_band", "facts_by_method", @@ -139,6 +151,7 @@ export const AGENT_TOOLS = [ "set_contact_socials", "set_field_value", "write_brief", + "write_deal_intelligence", "write_workspace_profile", ] as const; @@ -227,6 +240,84 @@ export function permittedTaskKind(kind: string | null | undefined): string { return kind && TASK_KIND_SET.has(kind) ? kind : OTHER; } +export const LIFECYCLE_ROLES = [ + "qualify", + "engage", + "advance", + "close", +] as const; + +const LIFECYCLE_ROLE_SET = new Set(LIFECYCLE_ROLES); + +export function permittedLifecycleRole( + role: string | null | undefined, +): string { + return role && LIFECYCLE_ROLE_SET.has(role) ? role : OTHER; +} + +export const AGENT_RUN_STATUSES = [ + "QUEUED", + "RUNNING", + "WAITING_FOR_APPROVAL", + "SUCCEEDED", + "FAILED", + "CANCELLED", +] as const; + +const AGENT_RUN_STATUS_SET = new Set(AGENT_RUN_STATUSES); + +export function permittedAgentRunStatus( + status: string | null | undefined, +): string { + return status && AGENT_RUN_STATUS_SET.has(status) ? status : OTHER; +} + +export const AGENT_TRIGGER_TYPES = [ + "MANUAL", + "SCHEDULE", + "EVENT", + "WEBHOOK", +] as const; + +const AGENT_TRIGGER_TYPE_SET = new Set(AGENT_TRIGGER_TYPES); + +export function permittedAgentTriggerType( + type: string | null | undefined, +): string { + return type && AGENT_TRIGGER_TYPE_SET.has(type) ? type : OTHER; +} + +export const TEAM_ACTION_TYPES = [ + "crm.activity.create", + "run.summary", + "slack.message.post", + "crm.outreach.recommend", +] as const; + +const TEAM_ACTION_TYPE_SET = new Set(TEAM_ACTION_TYPES); + +export function permittedTeamActionType( + type: string | null | undefined, +): string { + return type && TEAM_ACTION_TYPE_SET.has(type) ? type : OTHER; +} + +export const AGENT_ACTION_STATUSES = [ + "PLANNED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED", +] as const; + +const AGENT_ACTION_STATUS_SET = new Set(AGENT_ACTION_STATUSES); + +export function permittedAgentActionStatus( + status: string | null | undefined, +): string { + return status && AGENT_ACTION_STATUS_SET.has(status) ? status : OTHER; +} + export const SYNC_SOURCES = ["gmail", "calendar", "outlook"] as const; export type TelemetrySyncSource = (typeof SYNC_SOURCES)[number]; diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index 96be37136..0e5901143 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -1,24 +1,34 @@ export { + AGENT_ACTION_STATUSES, + AGENT_RUN_STATUSES, AGENT_TOOLS, + AGENT_TRIGGER_TYPES, ALLOWED_PROPERTIES, type AllowedProperty, bucket, dayBucket, EVE_TOOLS, EVIDENCE_KINDS, + LIFECYCLE_ROLES, OTHER, type Properties, type PropertyValue, permitted, + permittedAgentActionStatus, + permittedAgentRunStatus, + permittedAgentTriggerType, permittedErrorClass, permittedEvidenceKind, + permittedLifecycleRole, permittedMethod, permittedModelId, permittedRoute, permittedSyncSource, permittedTaskKind, + permittedTeamActionType, permittedTool, SYNC_SOURCES, + TEAM_ACTION_TYPES, } from "./allowlist"; export { capture, diff --git a/packages/telemetry/test/allowlist.spec.ts b/packages/telemetry/test/allowlist.spec.ts index e5494eac4..b7ffaef9c 100644 --- a/packages/telemetry/test/allowlist.spec.ts +++ b/packages/telemetry/test/allowlist.spec.ts @@ -10,12 +10,17 @@ import { EVIDENCE_KINDS, OTHER, permitted, + permittedAgentActionStatus, + permittedAgentRunStatus, + permittedAgentTriggerType, permittedErrorClass, permittedEvidenceKind, + permittedLifecycleRole, permittedMethod, permittedModelId, permittedRoute, permittedTaskKind, + permittedTeamActionType, permittedTool, } from "../src/allowlist"; @@ -57,6 +62,39 @@ describe("permitted", () => { it("names every property exactly once", () => { expect(new Set(ALLOWED_PROPERTIES).size).toBe(ALLOWED_PROPERTIES.length); }); + + it("keeps team-agent fleet properties", () => { + expect( + permitted({ + team_runs_by_status: { SUCCEEDED: 2 }, + team_cost_usd: 0.12, + contact_email: "hidden@example.test", + }), + ).toEqual({ + team_runs_by_status: { SUCCEEDED: 2 }, + team_cost_usd: 0.12, + }); + }); +}); + +describe("team agent permits", () => { + it("keeps known run statuses, trigger types, and roles", () => { + expect(permittedAgentRunStatus("SUCCEEDED")).toBe("SUCCEEDED"); + expect(permittedAgentTriggerType("EVENT")).toBe("EVENT"); + expect(permittedLifecycleRole("qualify")).toBe("qualify"); + expect(permittedTeamActionType("crm.activity.create")).toBe( + "crm.activity.create", + ); + expect(permittedAgentActionStatus("FAILED")).toBe("FAILED"); + }); + + it("buckets unknown team keys as other", () => { + expect(permittedAgentRunStatus("HACKED")).toBe(OTHER); + expect(permittedAgentTriggerType("sms")).toBe(OTHER); + expect(permittedLifecycleRole("send")).toBe(OTHER); + expect(permittedTeamActionType("crm.email.send")).toBe(OTHER); + expect(permittedAgentActionStatus("DONE")).toBe(OTHER); + }); }); describe("permittedTool", () => { diff --git a/packages/validation/src/agents.ts b/packages/validation/src/agents.ts index f1381b672..cd67845b4 100644 --- a/packages/validation/src/agents.ts +++ b/packages/validation/src/agents.ts @@ -91,7 +91,19 @@ export const capabilityResource = z.object({ label: z.string().trim().min(1).max(160), }); +export const LIFECYCLE_ROLES = [ + "qualify", + "engage", + "advance", + "close", +] as const; + +export type LifecycleRole = (typeof LIFECYCLE_ROLES)[number]; + +export const lifecycleRole = z.enum(LIFECYCLE_ROLES); + export const capabilities = z.object({ + lifecycleRole: lifecycleRole.optional(), actions: z.array(capabilityAction), dataScope: z.object({ mode: z.enum(["SELECTED", "WORKSPACE"]), @@ -103,3 +115,12 @@ export const capabilities = z.object({ export type Capabilities = z.infer; export type CapabilityAction = z.infer; export type CapabilityResource = z.infer; + +export function readLifecycleRole(manifest: unknown): LifecycleRole | null { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + return null; + } + const role = (manifest as { lifecycleRole?: unknown }).lifecycleRole; + const parsed = lifecycleRole.safeParse(role); + return parsed.success ? parsed.data : null; +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 97e8de82a..d30d91789 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -10,8 +10,10 @@ export type { InputOption, InputRequest, InputRequested, + LifecycleRole, Permission, } from "./agents"; +export { LIFECYCLE_ROLES, lifecycleRole, readLifecycleRole } from "./agents"; export type { AuthTest, Installation, JoinPayload, Reply } from "./slack"; export class InvalidInput extends Error { diff --git a/packages/validation/test/lifecycle-role.spec.ts b/packages/validation/test/lifecycle-role.spec.ts new file mode 100644 index 000000000..0521dddf4 --- /dev/null +++ b/packages/validation/test/lifecycle-role.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import { capabilities, readLifecycleRole } from "../src/agents"; + +describe("lifecycleRole validation", () => { + it("reads a valid role from a raw manifest", () => { + expect(readLifecycleRole({ lifecycleRole: "qualify" })).toBe("qualify"); + expect(readLifecycleRole({ lifecycleRole: "engage" })).toBe("engage"); + expect(readLifecycleRole({ lifecycleRole: "advance" })).toBe("advance"); + expect(readLifecycleRole({ lifecycleRole: "close" })).toBe("close"); + expect(readLifecycleRole({ lifecycleRole: "nope" })).toBeNull(); + expect(readLifecycleRole({})).toBeNull(); + }); + + it("accepts capabilities with an optional lifecycleRole", () => { + const base = { + actions: [{ type: "run.summary", provider: "crm", summary: "done" }], + dataScope: { mode: "WORKSPACE" as const, summary: "all", resources: [] }, + }; + expect(capabilities.parse(base).lifecycleRole).toBeUndefined(); + expect( + capabilities.parse({ ...base, lifecycleRole: "qualify" }).lifecycleRole, + ).toBe("qualify"); + expect( + capabilities.parse({ ...base, lifecycleRole: "engage" }).lifecycleRole, + ).toBe("engage"); + expect( + capabilities.parse({ ...base, lifecycleRole: "advance" }).lifecycleRole, + ).toBe("advance"); + expect( + capabilities.parse({ ...base, lifecycleRole: "close" }).lifecycleRole, + ).toBe("close"); + expect( + capabilities.safeParse({ ...base, lifecycleRole: "send" }).success, + ).toBe(false); + }); +});