feat(followup): o passo manda modelo aprovado do WhatsApp e o silêncio respeita o retorno agendado

Três defeitos medidos numa instalação real (25/09), no mesmo remarketing:

- O modo `template` do passo de ação só lia `message_templates`. Um fluxo
  apontado para um modelo APROVADO do canal (o único envio que passa com a
  janela de 24 h fechada) era publicado e morria no primeiro disparo. Agora o
  turno resolve o id também em `meta_templates`, pela definição da conexão da
  conversa, e envia como modelo (`isTemplate` na cadeia, nome e idioma no
  canal). Pendente ou com variável: o passo é pulado com o motivo.
- `fallback_template_id` da mensagem por IA era gravado, validado no publish
  e nunca lido. Com a janela fechada, o modelo aprovado sai no lugar da IA;
  aberta, a IA escreve como sempre.
- O fluxo de silêncio falava por cima do retorno que o agente combinou
  ("te escrevo no dia 30"). A varredura não inscreve quem tem retorno vivo, e a
  inscrição que já andava fica segurada até um dia depois do retorno, com o
  evento `held_by_return` legível no dossiê.

O construtor ganha o grupo "Aprovados no WhatsApp" no seletor de modelo
(rota `GET /api/v1/ai/followup-flows/modelos-aprovados`, viewer+), e o plano B
da IA só oferece modelo aprovado.

(cherry picked from commit b4b8e38f6fa3fcf71c6afe113648f7953a38ba09)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
jmpo
2026-09-26 12:52:24 -03:00
parent ab8ced41f8
commit b94446a5cb
16 changed files with 973 additions and 34 deletions
+18
View File
@@ -0,0 +1,18 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: O follow-up manda modelo aprovado do WhatsApp e respeita o retorno combinado
---
O passo de mensagem pronta de um follow-up passa a oferecer, além dos textos de
Ajustes → Modelos, os modelos aprovados no WhatsApp — o único envio que chega
ao cliente depois de 24 horas sem resposta. Antes, um passo apontado para um
modelo aprovado era publicado sem erro e falhava no primeiro disparo. A
mensagem escrita pela IA também passa a usar o modelo aprovado escolhido como
plano B quando a janela de 24 horas já fechou; até aqui esse campo era salvo e
nunca usado.
E quando o assistente combina com o cliente um retorno numa data ("te escrevo
no dia 30"), o follow-up de silêncio não escreve por cima: o contato não entra
no fluxo enquanto o retorno está agendado, e quem já estava nele fica em espera
até um dia depois do retorno. O detalhe do follow-up mostra o motivo.
@@ -0,0 +1,33 @@
/**
* GET /api/v1/ai/followup-flows/modelos-aprovados — os modelos aprovados do canal
* que um passo de fluxo consegue enviar sozinho (viewer+, como a leitura do fluxo).
*
* É a lista do seletor de modelo no construtor. A de `/api/v1/channels/templates`
* não serve: pede `admin` (quem edita fluxo é `manager`), não devolve o `id` que o
* passo grava e inclui o que o fluxo não consegue mandar. A regra do que entra
* mora em `lib/followup/modelos-aprovados.ts`.
*/
import { randomUUID } from "node:crypto";
import { ok, fail } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { modelosQueOFluxoEnvia, type LinhaDeModeloDoCanal } from "@/lib/followup/modelos-aprovados";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function GET(): Promise<Response> {
const requestId = randomUUID();
const authz = await requireRole("viewer", { requestId, resource: "followup_flows" });
if (!authz.ok) return authz.response;
const supabase = await createClient();
const { data, error } = await supabase
.from("meta_templates")
.select("id, name, language, status, parameter_format, components")
.eq("organization_id", authz.org.orgId)
.order("name");
if (error) return fail("internal_error", error.message, 500, { requestId });
return ok(modelosQueOFluxoEnvia((data ?? []) as LinhaDeModeloDoCanal[]), { requestId });
}
@@ -6,7 +6,9 @@ import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
@@ -14,6 +16,7 @@ import { Textarea } from "@/components/ui/textarea";
import { actionConfigSchema } from "@/lib/followup/graph-schema";
import { MODOS_DA_ACAO, opcoes, type ModoDaAcao } from "@/lib/followup/vocabulario";
import { useMessageTemplates } from "@/hooks/inbox/useMessageTemplates";
import { useModelosAprovadosDoFluxo } from "@/hooks/followup/useModelosAprovadosDoFluxo";
import { useT } from "@/hooks/i18n/useT";
import type { ConfigOf } from "./shared";
@@ -23,55 +26,88 @@ import type { ConfigOf } from "./shared";
* mão. Trata os três estados em vez de fingir que a lista sempre chega:
* carregando, vazia e erro — porque um seletor vazio sem explicação é o mesmo
* beco sem saída que o campo de UUID era, só que mais bonito.
*
* Duas origens, em grupos separados: os textos prontos (Ajustes → Modelos) e os
* modelos APROVADOS no WhatsApp. A diferença não é cosmética: com a janela de
* 24 h fechada, só o aprovado chega ao cliente — por isso o plano B da mensagem
* por IA (`soAprovados`) só oferece esse grupo.
*/
function SeletorDeModelo({
id,
valor,
onChange,
permiteVazio,
soAprovados,
}: {
id: string;
valor: string;
onChange: (templateId: string) => void;
permiteVazio: boolean;
soAprovados: boolean;
}) {
const t = useT();
const { data: modelos, isLoading, isError } = useMessageTemplates();
const textos = useMessageTemplates();
const aprovados = useModelosAprovadosDoFluxo();
const prontos = soAprovados ? [] : (textos.data ?? []);
const doCanal = aprovados.data ?? [];
if (isLoading) return <p className="text-xs text-text-muted">{t("Carregando seus modelos…")}</p>;
if (isError) {
if ((!soAprovados && textos.isLoading) || aprovados.isLoading) {
return <p className="text-xs text-text-muted">{t("Carregando seus modelos…")}</p>;
}
if ((soAprovados || textos.isError) && aprovados.isError) {
return (
<p className="text-xs text-error-fg">
{t("Não consegui carregar seus modelos de mensagem. Recarregue a página.")}
</p>
);
}
if (!modelos?.length) {
if (prontos.length === 0 && doCanal.length === 0) {
return (
<p className="text-xs text-text-muted">
{t("Você ainda não tem modelos de mensagem. Crie um em Ajustes → Modelos e ele aparece aqui.")}
{soAprovados
? t("Nenhum modelo aprovado no WhatsApp ainda. Crie um em Conexões → Modelos e ele aparece aqui quando for aprovado.")
: t("Você ainda não tem modelos de mensagem. Crie um em Ajustes → Modelos e ele aparece aqui.")}
</p>
);
}
const SEM_MODELO = "__nenhum__";
const escolhido = doCanal.find((m) => m.id === valor);
return (
<Select
value={valor === "" ? SEM_MODELO : valor}
onValueChange={(v) => onChange(v === SEM_MODELO ? "" : v)}
>
<SelectTrigger id={id}>
<SelectValue placeholder={t("Escolha um modelo")} />
</SelectTrigger>
<SelectContent>
{permiteVazio && <SelectItem value={SEM_MODELO}>{t("Nenhum")}</SelectItem>}
{modelos.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.title}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-2">
<Select
value={valor === "" ? SEM_MODELO : valor}
onValueChange={(v) => onChange(v === SEM_MODELO ? "" : v)}
>
<SelectTrigger id={id}>
<SelectValue placeholder={t("Escolha um modelo")} />
</SelectTrigger>
<SelectContent>
{permiteVazio && <SelectItem value={SEM_MODELO}>{t("Nenhum")}</SelectItem>}
{prontos.length > 0 && (
<SelectGroup>
<SelectLabel>{t("Textos prontos")}</SelectLabel>
{prontos.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.title}
</SelectItem>
))}
</SelectGroup>
)}
{doCanal.length > 0 && (
<SelectGroup>
<SelectLabel>{t("Aprovados no WhatsApp")}</SelectLabel>
{doCanal.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name} ({m.language})
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
{escolhido && <p className="whitespace-pre-line text-xs text-text-muted">{escolhido.texto}</p>}
</div>
);
}
@@ -176,11 +212,14 @@ export function ActionForm({
/>
</div>
<div className="space-y-2">
<Label htmlFor="action-fallback">{t("Se a IA não conseguir escrever, mandar este modelo")}</Label>
<Label htmlFor="action-fallback">
{t("Se a janela de 24 horas já tiver fechado, mandar este modelo aprovado no lugar da IA")}
</Label>
<SeletorDeModelo
id="action-fallback"
valor={fallbackTemplateId}
permiteVazio
soAprovados
onChange={(v) => {
setFallbackTemplateId(v);
commit({ mode, ...fields, fallbackTemplateId: v });
@@ -195,11 +234,15 @@ export function ActionForm({
id="action-template-id"
valor={templateId}
permiteVazio={false}
soAprovados={false}
onChange={(v) => {
setTemplateId(v);
commit({ mode, ...fields, templateId: v });
}}
/>
<p className="text-xs text-text-muted">
{t("Depois de 24 horas sem resposta do cliente, só um modelo aprovado no WhatsApp chega até ele.")}
</p>
</div>
)}
{error && <p className="text-xs text-error-fg">{error}</p>}
@@ -0,0 +1,20 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import type { ModeloAprovadoDoFluxo } from "@/lib/followup/modelos-aprovados";
/**
* Modelos aprovados do canal que um passo de fluxo consegue enviar sozinho — o
* único envio que passa com a janela de 24 h fechada. Ver
* `lib/followup/modelos-aprovados.ts` para o que entra na lista e por quê.
*/
export function useModelosAprovadosDoFluxo() {
return useQuery({
queryKey: ["followup-modelos-aprovados"],
queryFn: async () =>
apiClient.get<{ data: ModeloAprovadoDoFluxo[] }>("/api/v1/ai/followup-flows/modelos-aprovados"),
staleTime: 60_000,
select: (res) => res.data,
});
}
+157 -9
View File
@@ -28,6 +28,11 @@ import { getLeadContext, type LeadContext } from '../edge/crm/get-lead-context';
import { WahaChannelAdapter } from '../edge/channel/waha-adapter';
import { applySendOutcome } from '../edge/crm/send-message';
import { runBeforeSend } from '../guardrails/before-send';
import { definicaoNaConexao } from '@/lib/channels/linha-do-espelho';
import { estadoDaJanela } from '@/lib/channels/janela';
import { renderTemplateBody } from '@/lib/channels/meta/render-template';
import { isStatusSendable } from '@/lib/channels/meta/template-binding';
import { deriveTemplateContract } from '@/lib/channels/meta/template-contract';
import { camadaLigada, lerCamadasDaOrg } from '../guardrails/camadas-da-org';
import { classifyPromise } from '../guardrails/promise/semantic';
import { scheduleCronJob } from '../cron/scheduler';
@@ -82,8 +87,10 @@ export const followupTurnPayloadSchema = z
prompt_hint: z.string().optional(),
/** action mode `text` — enviado pela cadeia de guardrails, sem LLM. */
fixed_body: z.string().min(1).max(4000).optional(),
/** action mode `template` — corpo em `message_templates`. */
/** action mode `template` — `message_templates` (texto) ou `meta_templates` (modelo aprovado do canal). */
template_id: z.string().uuid().optional(),
/** action mode `ai_message` — modelo aprovado que sai no lugar da IA com a janela de 24 h fechada. */
fallback_template_id: z.string().uuid().optional(),
volta_index: z.number().int().optional(),
volta_total: z.number().int().optional(),
classes: z.array(z.string()).optional(),
@@ -337,6 +344,7 @@ export function createFollowupTurnHandler(deps: FollowupTurnDeps) {
promptHint: payload.prompt_hint,
fixedBody: payload.fixed_body,
templateId: payload.template_id,
fallbackTemplateId: payload.fallback_template_id,
voltaIndex: payload.volta_index,
voltaTotal: payload.volta_total,
classes: payload.classes,
@@ -414,6 +422,7 @@ async function runFlowDrivenTurn(
promptHint: string | undefined;
fixedBody: string | undefined;
templateId: string | undefined;
fallbackTemplateId: string | undefined;
voltaIndex: number | undefined;
voltaTotal: number | undefined;
classes: string[] | undefined;
@@ -434,10 +443,31 @@ async function runFlowDrivenTurn(
const runLog = withFields(deps.log, { job_id: job.id, tenant_id: target.tenantId, lead_id: target.leadId, enrollment_id: enrollmentId });
if (input.purpose === 'send_message') {
const body = await resolveFlowSendBody(pool, target.tenantId, input);
if (body !== null) {
let passo = await resolveFlowSendBody(pool, target.tenantId, target.channelSessionId, input);
// O PLANO B DA MENSAGEM POR IA. Com a janela de 24 h fechada, o canal recusa
// qualquer texto livre — o da IA inclusive —, e o passo terminava sem mandar
// nada. A tela prometia "se a IA não conseguir escrever, mandar este modelo"
// e o campo era gravado, validado e nunca lido. Só vale para modelo APROVADO
// do canal: um texto de `message_templates` seria recusado pela mesma janela,
// então nesse caso o turno segue para a IA como sempre seguiu.
if (
passo === null &&
input.fallbackTemplateId !== undefined &&
(await janelaFechada(pool, target, clock()))
) {
passo = await resolveModeloAprovado(pool, target.tenantId, target.channelSessionId, input.fallbackTemplateId);
}
if (passo !== null && passo.tipo === 'recusado') {
runLog.info('passo do fluxo pulado — o modelo não pode sair', { motivo: passo.motivo });
await complete(pool, { jobId: job.id, jobClaim: claimOfJob(job), organizationId: target.tenantId, enrollmentId, nodeId, result: { kind: 'skipped', reason: passo.motivo } });
return;
}
if (passo !== null) {
// Texto do operador: sem camada semântica (ver o cabeçalho de sendFixedOutbound).
const desfecho = await sendFixedOutbound(deps, job, pool, ctx, clock, target, body, false);
const desfecho = await sendFixedOutbound(
deps, job, pool, ctx, clock, target, passo.body, false,
passo.tipo === 'modelo_aprovado' ? passo.modelo : undefined,
);
// TODO OS TRÊS DESFECHOS VOLTAM PARA O ENROLLMENT. O adiado era o que não
// voltava, e o silêncio dele custava o enrollment inteiro: o motor ficava
// rechecando um turno que ninguém ia fechar e, esgotado o orçamento do
@@ -568,18 +598,36 @@ function interpolarVoltaDoPayload(texto: string, index: number | undefined, tota
return texto.replaceAll('{{volta}}', String(index)).replaceAll('{{voltas}}', String(total));
}
/**
* O que um passo de envio sem IA manda.
*
* `modelo_aprovado` carrega, além do corpo RENDERIZADO (é ele que os gates de
* conteúdo avaliam), o nome e o idioma que o canal precisa para disparar o modelo.
* `recusado` é configuração que não pode sair — o passo é pulado com o motivo, em
* vez de a fila re-tentar até matar a inscrição por algo que tempo não conserta.
*/
type PassoSemIa =
| { tipo: 'texto'; body: string }
| {
tipo: 'modelo_aprovado';
body: string;
modelo: { name: string; language: string; values: Record<string, string> };
}
| { tipo: 'recusado'; motivo: string };
async function resolveFlowSendBody(
pool: pg.Pool,
tenantId: string,
channelSessionId: string,
input: {
fixedBody: string | undefined;
templateId: string | undefined;
voltaIndex: number | undefined;
voltaTotal: number | undefined;
},
): Promise<string | null> {
): Promise<PassoSemIa | null> {
if (input.fixedBody !== undefined) {
return interpolarVoltaDoPayload(input.fixedBody, input.voltaIndex, input.voltaTotal);
return { tipo: 'texto', body: interpolarVoltaDoPayload(input.fixedBody, input.voltaIndex, input.voltaTotal) };
}
if (input.templateId === undefined) return null;
const { rows } = await pool.query<{ body: string }>(
@@ -587,10 +635,99 @@ async function resolveFlowSendBody(
[tenantId, input.templateId],
);
const body = rows[0]?.body;
if (body === undefined || body.length === 0) {
if (body !== undefined && body.length > 0) {
return { tipo: 'texto', body: interpolarVoltaDoPayload(body, input.voltaIndex, input.voltaTotal) };
}
// Não é texto pronto: pode ser um modelo APROVADO do canal. Até aqui o passo só
// lia `message_templates`, e um fluxo apontado para um modelo aprovado — o único
// envio que passa com a janela de 24 h fechada — morria neste `throw` no primeiro
// disparo, depois de o editor ter aceitado e publicado o grafo.
const aprovado = await resolveModeloAprovado(pool, tenantId, channelSessionId, input.templateId);
if (aprovado === null) {
throw new Error('followup_turn sem modelo de mensagem — o template_id do passo não existe nesta organização');
}
return interpolarVoltaDoPayload(body, input.voltaIndex, input.voltaTotal);
return aprovado;
}
/**
* Um modelo aprovado do canal (`meta_templates.id`), pronto para sair NESTA
* conexão. `null` = o id não é de modelo do canal nesta organização.
*
* O id aponta uma linha, mas quem vale é a definição da conexão da conversa
* (`definicaoNaConexao`, a mesma regra do `send_template` do agente): dois números
* podem espelhar o mesmo nome, e disparar a linha de outra conta é recusa certa.
*
* O fluxo não tem de onde tirar valor para variável, então modelo com `{{1}}` é
* recusado com o motivo — mandar o marcador cru ao cliente seria pior.
*/
async function resolveModeloAprovado(
pool: pg.Pool,
tenantId: string,
channelSessionId: string,
metaTemplateId: string,
): Promise<PassoSemIa | null> {
const { rows } = await pool.query<{ name: string; language: string }>(
`select name, language from meta_templates where organization_id = $1 and id = $2 limit 1`,
[tenantId, metaTemplateId],
);
const alvo = rows[0];
if (alvo === undefined) return null;
const linha = await definicaoNaConexao<{ components: unknown; parameter_format: string; status: string }>(
pool,
['components', 'parameter_format', 'status'],
{ organizationId: tenantId, name: alvo.name, language: alvo.language, channelSessionId },
);
if (linha === null) {
return { tipo: 'recusado', motivo: `O modelo "${alvo.name}" não existe no número desta conversa.` };
}
if (!isStatusSendable(linha.status)) {
return {
tipo: 'recusado',
motivo: `O modelo "${alvo.name}" está ${linha.status} na plataforma — só modelo aprovado pode ser enviado.`,
};
}
const meta = { name: alvo.name, language: alvo.language, parameterFormat: linha.parameter_format };
const contrato = deriveTemplateContract({
name: alvo.name,
language: alvo.language,
parameter_format: linha.parameter_format,
components: linha.components as never,
});
if (contrato.slots.length > 0) {
return {
tipo: 'recusado',
motivo: `O modelo "${alvo.name}" tem variáveis, e o fluxo não tem de onde tirar os valores. Use um modelo sem variáveis.`,
};
}
return {
tipo: 'modelo_aprovado',
body: renderTemplateBody(linha.components, {}, meta),
modelo: { name: alvo.name, language: alvo.language, values: {} },
};
}
/**
* A janela de 24 h desta conversa está fechada? Mesmo insumo do gate da cadeia
* (`readLastInboundAt` em before-send.ts: a conversa do contato NESTE número) e a
* mesma conta (`estadoDaJanela`) — decidir aqui com outra régua faria o plano B
* sair quando a cadeia deixaria a IA passar, ou o contrário.
*/
async function janelaFechada(pool: pg.Pool, target: ReentrySendTarget, agora: Date): Promise<boolean> {
const { rows } = await pool.query<{ provider: string | null; last_inbound_at: Date | null }>(
`select s.provider,
(select c.last_inbound_at from conversations c
where c.organization_id = s.organization_id and c.contact_id = $3
and c.channel_session_id = s.id
order by c.last_inbound_at desc nulls last
limit 1) as last_inbound_at
from channel_sessions s
where s.organization_id = $1 and s.id = $2`,
[target.tenantId, target.channelSessionId, target.leadId],
);
const linha = rows[0];
if (linha === undefined) return false;
const ultimo = linha.last_inbound_at === null ? null : new Date(linha.last_inbound_at).toISOString();
return estadoDaJanela(linha.provider, ultimo, agora).tipo === 'fechada';
}
/**
@@ -630,6 +767,12 @@ async function sendFixedOutbound(
body: string,
/** `true` só na re-entrada por template — ver o cabeçalho. */
comCamadaSemantica: boolean,
/**
* Presente = `body` é um modelo APROVADO do canal, já renderizado. A cadeia inteira
* continua valendo (stop, LGPD, horário); só o gate da janela de 24 h o deixa
* passar, que é o que um modelo aprovado é — como no `send_template` do agente.
*/
modelo?: { name: string; language: string; values: Record<string, string> },
): Promise<EnvioFixoDesfecho> {
const { tenantId, leadId, channelSessionId, conversationId } = target;
const runLog = withFields(deps.log, { job_id: job.id, tenant_id: tenantId, lead_id: leadId });
@@ -674,6 +817,7 @@ async function sendFixedOutbound(
now: clock(),
sleep: deps.sleep,
lgpd: context.lgpd,
...(modelo !== undefined ? { isTemplate: true } : {}),
...(deps.knobs.disclosureMode !== undefined ? { disclosureMode: deps.knobs.disclosureMode } : {}),
...(camadaSemanticaLigada
? {
@@ -687,7 +831,11 @@ async function sendFixedOutbound(
),
}
: {}),
send: (finalBody) => channel.send({ tenantId, leadId, jobId: job.id, jobClaim:claimOfJob(job), seq: 1, conversationId, body: finalBody }),
send: (finalBody) =>
channel.send({
tenantId, leadId, jobId: job.id, jobClaim: claimOfJob(job), seq: 1, conversationId, body: finalBody,
...(modelo !== undefined ? { template: modelo } : {}),
}),
});
if (chain.status === 'vetoed') {
+56 -2
View File
@@ -49,6 +49,8 @@ import {
type AvisoRecuperacaoEsgotada,
} from "./no-show-recuperacao-esgotada";
import { interpolarDestino, persistirRespostaFollowupSupabase } from "./persistir-resposta";
import { quandoDoRetornoVivo, reavaliarDepoisDoRetorno } from "./retorno-segura-o-fluxo";
import { triggerConfigSchema } from "./api-schemas";
const MAX_STEPS = 80;
const CLAIM_LEASE_SECONDS = 120;
@@ -82,9 +84,19 @@ export interface FollowupJobRequest {
purpose: "send_message" | "classify" | "plan_timing";
/** action (mode 'ai_message') — Task 5.1: repassado ao turno pra virar o bloco de orientação. */
prompt_hint?: string;
/**
* action (mode 'ai_message') — modelo APROVADO do canal (`meta_templates.id`) que
* sai no lugar da IA quando a janela de 24 h da conversa já fechou. Sem ele, fora
* da janela o canal recusa o texto livre e o passo não manda nada.
*/
fallback_template_id?: string;
/** action (mode 'text') — corpo pronto; o turno envia sem chamar o modelo. */
fixed_body?: string;
/** action (mode 'template') — id em `message_templates`; o turno carrega o corpo e envia sem modelo. */
/**
* action (mode 'template') — id em `message_templates` (texto pronto) OU em
* `meta_templates` (modelo aprovado do canal, o único que sai com a janela de 24 h
* fechada). O turno resolve qual dos dois é e envia sem modelo de IA.
*/
template_id?: string;
volta_index?: number;
volta_total?: number;
@@ -102,6 +114,11 @@ export interface FollowupJobRequest {
export interface AdminClient {
assertServiceBoundary?(enrollment: EnrollmentRow): Promise<void>;
assertAgenda?(enrollment:EnrollmentRow):Promise<void>;
/**
* Até quando um retorno agendado segura esta inscrição (ISO), ou `null` quando
* nada a segura. Só fluxo de silêncio — ver `retorno-segura-o-fluxo.ts`.
*/
retornoQueSeguraOFluxo?(enrollment: EnrollmentRow): Promise<string | null>;
claimDueEnrollments(limit: number, leaseSeconds: number): Promise<EnrollmentRow[]>;
loadFlowGraph(orgId: string, versionId: string): Promise<FlowGraph | null>;
loadLeadFacts(orgId: string, contactId: string): Promise<{
@@ -244,7 +261,10 @@ function turnPayloadExtras(
events: EnrollmentEventRef[] = [],
): Partial<FollowupJobRequest["payload"]> {
if (node.type === "action" && node.config.mode === "ai_message") {
return { prompt_hint: interpolarVolta(node.config.prompt_hint, events) };
return {
prompt_hint: interpolarVolta(node.config.prompt_hint, events),
...(node.config.fallback_template_id ? { fallback_template_id: node.config.fallback_template_id } : {}),
};
}
if (node.type === "action" && node.config.mode === "text") {
return { fixed_body: interpolarVolta(node.config.body, events) };
@@ -577,6 +597,26 @@ async function processEnrollment(
return;
}
// O RETORNO AGENDADO FALA PRIMEIRO. Com um "te escrevo no dia 30" a caminho, o
// fluxo de silêncio não insiste por cima: fica segurado até um dia depois do
// retorno. Resposta do cliente passa (quem reage ao que ele disse não é
// insistência) — e, no fluxo de silêncio, é o `cancel_on_reply` que decide.
if (inboundBodyOverride === undefined) {
const seguraAte = await db.retornoQueSeguraOFluxo?.(enrollment);
if (seguraAte) {
await db.insertEnrollmentEvent({
organization_id: enrollment.organization_id,
enrollment_id: enrollment.id,
node_id: enrollment.current_node_id,
event_type: "held_by_return",
payload: { next_eval_at: seguraAte },
idempotency_key: `${enrollment.current_node_id}:${enrollment.steps_taken}:retorno:${seguraAte}`,
});
await db.updateEnrollment(enrollment.id, enrollment.organization_id, { next_eval_at: seguraAte, claimed_until: null });
return;
}
}
if (enrollment.steps_taken > MAX_STEPS) {
await markDead(db, clock, enrollment, "max_steps");
@@ -781,6 +821,20 @@ export function createSupabaseAdminClient(admin: SupabaseClient): AdminClient {
const revisions=new Map<string,number>();
return {
async assertServiceBoundary(enrollment) { if(!revisions.has(enrollment.id) && enrollment.revision!==undefined) revisions.set(enrollment.id,enrollment.revision); await assertServiceBoundarySupabase(admin, enrollment.service_boundary ?? null); },
async retornoQueSeguraOFluxo(enrollment) {
const quando = await quandoDoRetornoVivo(admin, enrollment.organization_id, enrollment.contact_id);
if (quando === null) return null;
const { data, error } = await admin
.from("followup_flow_pointers")
.select("trigger_config")
.eq("organization_id", enrollment.organization_id)
.eq("id", enrollment.pointer_id)
.maybeSingle();
if (error) throw new Error(`pointer_query_failed: ${error.message}`);
const gatilho = triggerConfigSchema.safeParse((data as { trigger_config?: unknown } | null)?.trigger_config);
if (!gatilho.success || gatilho.data.kind !== "silence") return null;
return reavaliarDepoisDoRetorno(quando);
},
async assertAgenda(enrollment){await assertAgendaEffectSupabase(admin,{organizationId:enrollment.organization_id,contactId:enrollment.contact_id,enrollmentId:enrollment.id,nodeId:enrollment.current_node_id});},
async claimDueEnrollments(limit, leaseSeconds) {
const { data, error } = await admin.rpc("fn_claim_due_followup_enrollments", {
+11
View File
@@ -391,6 +391,17 @@ export function descreveEvento(
...motor,
};
}
case "held_by_return": {
// Como o adiamento pela janela: segurar NÃO é falhar. Sem esta linha o
// operador veria o fluxo parado por dias sem saber que ele está esperando
// o retorno que o agente combinou com o cliente.
const ate = quandoLegivel(p.next_eval_at, idioma);
return {
titulo: "Segurou o fluxo por causa de um retorno agendado",
detalhe: ate ? `volta a andar em ${ate}, um dia depois do retorno` : null,
...motor,
};
}
case "action_sent":
return { titulo: "Mensagem enviada", detalhe: null, ...motor };
case "ai_classified":
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { modelosQueOFluxoEnvia, type LinhaDeModeloDoCanal } from "./modelos-aprovados";
function linha(id: string, status: string, texto: string): LinhaDeModeloDoCanal {
return {
id,
name: `modelo_${id}`,
language: "es",
status,
parameter_format: "POSITIONAL",
components: [
{ type: "BODY", text: texto },
{ type: "BUTTONS", buttons: [{ type: "QUICK_REPLY", text: "Sí" }] },
],
};
}
describe("modelos que um passo de fluxo consegue mandar sozinho", () => {
it("⭐ só aprovado e sem variável — o que o turno do fluxo depois pula não é oferecido", () => {
const lista = modelosQueOFluxoEnvia([
linha("a", "APPROVED", "Sale ₲125.000. ¿Te lo reservamos?"),
linha("b", "PENDING", "ainda em análise"),
linha("c", "APPROVED", "Hola {{1}}, ¿seguís interesado?"),
linha("d", "REJECTED", "recusado"),
]);
expect(lista.map((m) => m.id)).toEqual(["a"]);
});
it("devolve o texto que o cliente lê, para escolher pelo conteúdo", () => {
const [m] = modelosQueOFluxoEnvia([linha("a", "APPROVED", "Sale ₲125.000.")]);
expect(m).toEqual({ id: "a", name: "modelo_a", language: "es", texto: "Sale ₲125.000." });
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* Os modelos APROVADOS do canal que um passo de fluxo consegue mandar sozinho.
*
* O construtor de fluxo só oferecia os textos prontos de `message_templates` — e
* texto livre é justamente o que o canal oficial recusa quando a janela de 24 h
* fechou, que é quando um fluxo de reengajamento mais precisa falar. Esta é a
* lista que falta: o que a plataforma aprovou, NESTA organização.
*
* Duas exclusões, pelas mesmas razões que o turno do fluxo recusa o envio
* (`resolveModeloAprovado` em `lib/agent-engine/agent/followup-turn.ts`) — a
* tela não oferece o que o motor depois pula:
* - status que não dispara (pendente, rejeitado, pausado);
* - modelo com variável: o fluxo não tem de onde tirar o valor.
*/
import { renderTemplateBody } from "@/lib/channels/meta/render-template";
import { isStatusSendable } from "@/lib/channels/meta/template-binding";
import { deriveTemplateContract } from "@/lib/channels/meta/template-contract";
export interface LinhaDeModeloDoCanal {
id: string;
name: string;
language: string;
status: string;
parameter_format: string | null;
components: unknown;
}
export interface ModeloAprovadoDoFluxo {
/** `meta_templates.id` — o que o passo grava em `template_id`/`fallback_template_id`. */
id: string;
name: string;
language: string;
/** O texto que o cliente lê, para a pessoa escolher pelo conteúdo e não pelo nome técnico. */
texto: string;
}
export function modelosQueOFluxoEnvia(linhas: LinhaDeModeloDoCanal[]): ModeloAprovadoDoFluxo[] {
const saida: ModeloAprovadoDoFluxo[] = [];
for (const l of linhas) {
if (!isStatusSendable(l.status)) continue;
const contrato = deriveTemplateContract({
name: l.name,
language: l.language,
...(l.parameter_format ? { parameter_format: l.parameter_format } : {}),
components: l.components as never,
});
if (contrato.slots.length > 0) continue;
saida.push({
id: l.id,
name: l.name,
language: l.language,
texto: renderTemplateBody(l.components, {}, {
name: l.name,
language: l.language,
...(l.parameter_format ? { parameterFormat: l.parameter_format } : {}),
}),
});
}
return saida;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* O RETORNO AGENDADO SEGURA O FLUXO DE SILÊNCIO.
*
* ─── O defeito ─────────────────────────────────────────────────────────────
*
* Medido numa instalação real (25/09/2026): a cliente disse "só recebo o
* salário no dia 30", o agente agendou o retorno para o dia 30 com
* `schedule_followup` e respondeu "te escrevo no dia 30, sem pressa". Uma hora
* depois, o fluxo de silêncio — que não sabia de retorno nenhum — voltou a
* escrever pedindo os dados de entrega; no dia seguinte repetiria a oferta que
* ela já tinha aceitado, e dois dias depois a "última oportunidade". O sistema
* desmentia, sozinho, a promessa que ele mesmo tinha feito.
*
* ─── A regra ───────────────────────────────────────────────────────────────
*
* Com um retorno VIVO para o contato, o fluxo de silêncio não fala por cima:
* - a varredura não inscreve o contato (`contatosComRetornoVivo`);
* - a inscrição que já estava andando fica segurada até `FOLGA_DEPOIS_DO_RETORNO_MS`
* depois do retorno (`quandoDoRetornoVivo` + `reavaliarDepoisDoRetorno`).
* Se o cliente responder ao retorno, o `cancel_on_reply` do fluxo o encerra
* como sempre; se não responder, o fluxo retoma de onde estava.
*
* Só o gatilho de SILÊNCIO: é ele que insiste com quem parou de falar, e é ele
* que contradiz "te escrevo no dia 30". Fluxo por etapa ou por caso responde a um
* FATO novo (o pedido foi confirmado, o caso foi aberto) e não deve esperar.
*
* ─── O que conta como retorno ──────────────────────────────────────────────
*
* Uma linha de `cron_jobs` `kind='at'`, `job_kind='followup_turn'`, agendada
* (`enabled`, sem `cancelled_at` — ver `situacaoDoRetorno` em `retorno.ts`) e que
* NÃO é o turno de um passo de fluxo. O motor de fluxo enfileira os próprios
* passos na mesma tabela e no mesmo `job_kind`, com `followup_enrollment_id` no
* payload; contá-los faria cada fluxo segurar a si mesmo.
*/
import type { SupabaseClient } from "@supabase/supabase-js";
/**
* Quanto o fluxo espera DEPOIS do retorno antes de retomar. Um dia: o tempo de a
* pessoa ler e responder ao que foi prometido. Menos que isso, o fluxo emendaria
* uma mensagem na outra no mesmo dia — o bombardeio que a regra existe para evitar.
*/
export const FOLGA_DEPOIS_DO_RETORNO_MS = 24 * 3_600_000;
export function reavaliarDepoisDoRetorno(quandoDoRetorno: string): string {
return new Date(Date.parse(quandoDoRetorno) + FOLGA_DEPOIS_DO_RETORNO_MS).toISOString();
}
function retornosVivos(admin: SupabaseClient, orgId: string) {
return admin
.from("cron_jobs")
.select("contact_id, next_run_at")
.eq("organization_id", orgId)
.eq("kind", "at")
.eq("job_kind", "followup_turn")
.eq("enabled", true)
.is("cancelled_at", null)
.is("payload->>followup_enrollment_id", null);
}
/** Contatos da organização com retorno vivo — a varredura de silêncio os pula. */
export async function contatosComRetornoVivo(admin: SupabaseClient, orgId: string): Promise<Set<string>> {
const { data, error } = await retornosVivos(admin, orgId);
if (error) throw new Error(`retorno_vivo_query_failed: ${error.message}`);
const ids = new Set<string>();
for (const row of (data ?? []) as Array<{ contact_id: string | null }>) {
if (row.contact_id) ids.add(row.contact_id);
}
return ids;
}
/**
* Quando dispara o retorno vivo MAIS TARDIO do contato, ou `null` sem retorno.
* O mais tardio porque é depois dele que o fluxo pode voltar a falar.
*/
export async function quandoDoRetornoVivo(
admin: SupabaseClient,
orgId: string,
contactId: string,
): Promise<string | null> {
const { data, error } = await retornosVivos(admin, orgId)
.eq("contact_id", contactId)
.order("next_run_at", { ascending: false })
.limit(1);
if (error) throw new Error(`retorno_vivo_query_failed: ${error.message}`);
const row = (data ?? [])[0] as { next_run_at: string } | undefined;
return row?.next_run_at ?? null;
}
@@ -79,6 +79,7 @@ describe("runSilenceSweep", () => {
{ id: "p-bom", organization_id: "org-2", active_version_id: "v2", threshold_minutes: 60, segments: [] },
],
loadSilentContactIds: async () => ["contato"],
loadContatosComRetornoVivo: async () => new Set<string>(),
loadTriggerNode: async () => ({ id: "t", pedeAgente: false }),
insertEnrollment: insert,
};
+19
View File
@@ -57,6 +57,7 @@ import {
type FollowupGateDb,
type NoDeGatilho,
} from "./agent-followup-gate";
import { contatosComRetornoVivo } from "./retorno-segura-o-fluxo";
export interface SilencePointer {
id: string;
@@ -72,6 +73,11 @@ export interface SilenceSweepDb {
loadActiveSilencePointers(): Promise<SilencePointer[]>;
/** Contact ids da org sem inbound desde `cutoffIso` (inclusive); `segments` vazio = todos. */
loadSilentContactIds(orgId: string, cutoffIso: string, segments: string[]): Promise<string[]>;
/**
* Contatos com RETORNO agendado vivo — quem tem um "te escrevo no dia 30" a
* caminho não entra no fluxo de silêncio. Ver `retorno-segura-o-fluxo.ts`.
*/
loadContatosComRetornoVivo(orgId: string): Promise<Set<string>>;
/** Nó `trigger` do grafo pinado + se o fluxo pede agente; `null` se version/nó não existir. */
loadTriggerNode(orgId: string, versionId: string): Promise<NoDeGatilho | null>;
/** Insere o enrollment nascendo no nó trigger; `inserted:false` = 23505 (já vivo nesse pointer) → skip. */
@@ -91,6 +97,8 @@ export interface SilenceSweepSummary {
pointers_gated_out: number;
enrolled: number;
skipped_existing: number;
/** Silenciosos que ficaram de fora porque já têm um retorno agendado. */
skipped_pending_return: number;
/**
* Pointers que FALHARAM nesta varredura (logados e pulados). Um pointer ruim
* — de uma empresa só — não pode calar a varredura de todas as outras: antes,
@@ -112,6 +120,7 @@ export async function runSilenceSweep(deps: SilenceSweepDeps): Promise<SilenceSw
pointers_gated_out: 0,
enrolled: 0,
skipped_existing: 0,
skipped_pending_return: 0,
pointers_failed: 0,
};
@@ -154,8 +163,14 @@ export async function runSilenceSweep(deps: SilenceSweepDeps): Promise<SilenceSw
const cutoffIso = new Date(clock().getTime() - pointer.threshold_minutes * 60_000).toISOString();
const contactIds = await db.loadSilentContactIds(pointer.organization_id, cutoffIso, pointer.segments);
const nextEvalAt = clock().toISOString();
const comRetorno =
contactIds.length > 0 ? await db.loadContatosComRetornoVivo(pointer.organization_id) : new Set<string>();
for (const contactId of contactIds) {
if (comRetorno.has(contactId)) {
summary.skipped_pending_return++;
continue;
}
const { inserted } = await db.insertEnrollment({
organization_id: pointer.organization_id,
pointer_id: pointer.id,
@@ -319,6 +334,10 @@ export function createSupabaseSilenceSweepDb(admin: SupabaseClient): SilenceSwee
return silentIds;
},
loadContatosComRetornoVivo(orgId) {
return contatosComRetornoVivo(admin, orgId);
},
async loadTriggerNode(orgId, versionId) {
const { data, error } = await admin
.from("followup_flow_versions")
+14 -1
View File
@@ -2063,7 +2063,17 @@ export const DICIONARIO: Traducoes = {
"Mensagem escrita pela IA": { es: "Mensaje escrito por la IA" },
"Modelo de mensagem pronto": { es: "Plantilla de mensaje predefinida" },
"Instrução para a IA": { es: "Instrucción para la IA" },
"Se a IA não conseguir escrever, mandar este modelo": { es: "Si la IA no puede redactar el mensaje, enviar esta plantilla" },
"Se a janela de 24 horas já tiver fechado, mandar este modelo aprovado no lugar da IA": {
es: "Si la ventana de 24 horas ya se cerró, enviar esta plantilla aprobada en lugar de la IA",
},
"Nenhum modelo aprovado no WhatsApp ainda. Crie um em Conexões → Modelos e ele aparece aqui quando for aprovado.": {
es: "Todavía no hay plantillas aprobadas en WhatsApp. Crea una en Conexiones → Plantillas y aparecerá aquí cuando se apruebe.",
},
"Textos prontos": { es: "Textos predefinidos" },
"Aprovados no WhatsApp": { es: "Aprobadas en WhatsApp" },
"Depois de 24 horas sem resposta do cliente, só um modelo aprovado no WhatsApp chega até ele.": {
es: "Después de 24 horas sin respuesta del cliente, solo le llega una plantilla aprobada en WhatsApp.",
},
"Modelo de mensagem": { es: "Plantilla de mensaje" },
"Nota (opcional)": { es: "Nota (opcional)" },
"Fluxo reprovado na validação — corrija os nós destacados.": {
@@ -6827,6 +6837,9 @@ export const DICIONARIO: Traducoes = {
"Pediu ao agente para interpretar a resposta": { es: "Le pidió al agente que interpretara la respuesta" },
"Conferiu se a mensagem já tinha saído": { es: "Verificó si el mensaje ya había salido" },
"Mensagem enviada": { es: "Mensaje enviado" },
"Segurou o fluxo por causa de um retorno agendado": {
es: "Frenó el flujo por un regreso programado",
},
"O agente interpretou a resposta": { es: "El agente interpretó la respuesta" },
"Fluxo concluído": { es: "Flujo concluido" },
"O fluxo parou de tentar": { es: "El flujo dejó de intentarlo" },
@@ -137,6 +137,18 @@ function silenceSweepDb(): SilenceSweepDb {
.filter((r) => segments.length === 0 || segments.some((s) => r.tags.includes(s)))
.map((r) => r.contact_id);
},
// Mesma régua de `lib/followup/retorno-segura-o-fluxo.ts`: retorno agendado,
// que não é o turno de um passo de fluxo.
async loadContatosComRetornoVivo(orgId) {
const { rows } = await pool.query<{ contact_id: string }>(
`select contact_id from cron_jobs
where organization_id = $1 and kind = 'at' and job_kind = 'followup_turn'
and enabled and cancelled_at is null and contact_id is not null
and payload->>'followup_enrollment_id' is null`,
[orgId],
);
return new Set(rows.map((r) => r.contact_id));
},
async loadTriggerNode(orgId, versionId) {
const { rows } = await pool.query<{ graph: FlowGraph }>(
`select graph from followup_flow_versions where organization_id = $1 and id = $2`,
@@ -0,0 +1,236 @@
/**
* O PASSO DE FLUXO MANDA O MODELO APROVADO DO CANAL.
*
* ## O defeito
*
* O modo `template` do passo de ação só lia `message_templates` — os textos
* prontos de Ajustes → Modelos. Texto livre é exatamente o que o canal oficial
* recusa quando a janela de 24 h fechou, e é aí que um fluxo de reengajamento
* fala. Um fluxo apontado para um modelo APROVADO (`meta_templates`) passava na
* validação, era publicado, e morria no primeiro disparo com "o template_id do
* passo não existe nesta organização". Medido numa instalação real: o passo
* "Última oportunidade (plantilla)" de um remarketing nunca teria saído.
*
* O irmão do defeito: `fallback_template_id` da mensagem por IA ("se a IA não
* conseguir escrever, mandar este modelo") era gravado, validado no publish e
* nunca lido por ninguém em runtime.
*
* ## O que este arquivo prende
*
* - modelo aprovado sai COMO MODELO: a cadeia recebe `isTemplate` (só o gate da
* janela o deixa passar) e o canal recebe nome e idioma;
* - modelo que não pode sair (pendente, com variável) pula o passo com o motivo,
* sem tocar na cadeia — em vez de a fila re-tentar até matar a inscrição;
* - o plano B da IA sai com a janela FECHADA e só com ela: aberta, a IA escreve;
* - controle: texto pronto continua saindo como texto.
*
* ## O que NÃO prova
*
* Nada com Postgres real nem com a plataforma: que o canal aceita o modelo é do
* adapter (`sendTemplate`), não deste turno.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { JobRow } from "@/lib/agent-engine/queue/queue";
const runBeforeSend = vi.fn(async (args: Record<string, unknown>) => {
await (args.send as (b: string) => Promise<unknown>)(args.body as string);
return { status: "sent", outcome: { kind: "sent" }, trace: [] };
});
vi.mock("@/lib/agent-engine/guardrails/before-send", () => ({ runBeforeSend }));
vi.mock("@/lib/agent-engine/agent/human-handoff", () => ({ isLeadInHandoff: vi.fn(async () => false) }));
vi.mock("@/lib/agent-engine/edge/crm/get-lead-context", () => ({
getLeadContext: vi.fn(async () => ({
ok: true,
context: { contact: { is_blocked: false } },
lgpd: { isAnonymized: false, isProspecting: false, legalBasis: {} },
})),
}));
const runAgentTurn = vi.fn(async () => undefined);
vi.mock("@/lib/agent-engine/agent/inbound-turn", async (original) => ({
...(await original<typeof import("@/lib/agent-engine/agent/inbound-turn")>()),
runAgentTurn,
}));
vi.mock("@/lib/agent-engine/edge/crm/send-ledger", () => ({
resultadoDoEnvioDoFollowup: vi.fn(async () => ({ kind: "sent" })),
}));
const ORG = "org-1";
const LEAD = "lead-1";
const CONVERSA = "conversa-1";
const CANAL = "canal-1";
const MODELO_ID = "22222222-2222-4222-8222-222222222222";
const HORA = 3_600_000;
const boundary = { organization_id: ORG, contact_id: LEAD, conversation_id: CONVERSA, service_revision: 1, demanda_id: null, demanda_revision: null };
function job(payload: Record<string, unknown>): JobRow {
return {
id: "job-1",
organization_id: ORG,
contact_id: LEAD,
kind: "followup_turn",
source_event_id: null,
payload: {
followup_enrollment_id: "11111111-1111-4111-8111-111111111111",
node_id: "passo",
purpose: "send_message",
...payload,
service_boundary: boundary,
},
status: "running",
priority: 0,
run_after: new Date(),
attempts: 1,
max_attempts: 3,
last_error: null,
locked_by: "w1",
locked_at: new Date(),
created_at: new Date(),
} as JobRow;
}
const CORPO = "¡Hola! ¿Seguís pensando en el Pico? Sale ₲125.000. ¿Te lo reservamos?";
interface Cenario {
/** corpo em `message_templates` (texto pronto) */
texto?: string;
/** a linha do modelo do canal, ou ausente */
modelo?: { status: string; texto?: string };
/** último inbound da conversa, em horas atrás (`null` = nunca escreveu) */
ultimoInboundHa?: number | null;
}
function fakePool(c: Cenario) {
const query = vi.fn(async (sql: string): Promise<{ rows: Array<Record<string, unknown>> }> => {
if (sql.includes("d.fechada_em::text")) return { rows: [{ ...boundary, status: "open", demanda_fechada_em: null }] };
// A ordem importa: a definição da conexão e a janela citam outras tabelas no corpo.
if (/from meta_templates t/.test(sql)) {
return c.modelo
? { rows: [{ components: [{ type: "BODY", text: c.modelo.texto ?? CORPO }], parameter_format: "POSITIONAL", status: c.modelo.status }] }
: { rows: [] };
}
if (/select name, language from meta_templates/.test(sql)) {
return c.modelo ? { rows: [{ name: "recordatorio_pico", language: "es" }] } : { rows: [] };
}
if (/from message_templates/.test(sql)) return { rows: c.texto ? [{ body: c.texto }] : [] };
if (/from channel_sessions s/.test(sql)) {
const ha = c.ultimoInboundHa;
return {
rows: [{ provider: "zernio", last_inbound_at: ha === null || ha === undefined ? null : new Date(Date.now() - ha * HORA) }],
};
}
if (/from conversations/.test(sql)) return { rows: [{ id: CONVERSA, channel_session_id: CANAL, archived_at: null }] };
return { rows: [] };
});
return { query } as never;
}
function deps() {
const send = vi.fn(async (_input: Record<string, unknown>) => ({ ok: true }));
const completeFollowupTurn = vi.fn(async () => undefined);
const d = {
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
crmCfg: {},
llmCfg: {},
knobs: {},
channel: () => ({ send }),
completeFollowupTurn,
} as never;
return { d, send, completeFollowupTurn };
}
function resultado(completeFollowupTurn: ReturnType<typeof vi.fn>): { kind: string; reason?: string } {
const entrada = (completeFollowupTurn.mock.calls[0] as unknown[] | undefined)?.[1] as { result: { kind: string; reason?: string } } | undefined;
return entrada?.result ?? { kind: "(não chamou)" };
}
let criarHandler: typeof import("@/lib/agent-engine/agent/followup-turn").createFollowupTurnHandler;
beforeAll(async () => {
({ createFollowupTurnHandler: criarHandler } = await import("@/lib/agent-engine/agent/followup-turn"));
}, 60_000);
beforeEach(() => {
runBeforeSend.mockClear();
runAgentTurn.mockClear();
});
describe("passo `template` apontado para um modelo aprovado do canal", () => {
it("⭐ sai como MODELO: a cadeia sabe que é modelo e o canal recebe nome e idioma", async () => {
const { d, send, completeFollowupTurn } = deps();
await criarHandler(d)(job({ template_id: MODELO_ID }), fakePool({ modelo: { status: "APPROVED" }, ultimoInboundHa: 72 }), { workerId: "w1" });
expect(runBeforeSend).toHaveBeenCalledTimes(1);
const cadeia = runBeforeSend.mock.calls[0]![0];
expect(cadeia.isTemplate).toBe(true);
expect(cadeia.body).toBe(CORPO);
expect(send.mock.calls[0]![0].template).toEqual({ name: "recordatorio_pico", language: "es", values: {} });
expect(resultado(completeFollowupTurn).kind).toBe("sent");
});
it("modelo ainda em análise: o passo é pulado com o motivo, sem tocar na cadeia", async () => {
const { d, completeFollowupTurn } = deps();
await criarHandler(d)(job({ template_id: MODELO_ID }), fakePool({ modelo: { status: "PENDING" } }), { workerId: "w1" });
expect(runBeforeSend).not.toHaveBeenCalled();
const r = resultado(completeFollowupTurn);
expect(r.kind).toBe("skipped");
expect(r.reason).toContain("PENDING");
});
it("modelo com variável: pulado — o fluxo não tem de onde tirar o {{1}}", async () => {
const { d, completeFollowupTurn } = deps();
await criarHandler(d)(
job({ template_id: MODELO_ID }),
fakePool({ modelo: { status: "APPROVED", texto: "Hola {{1}}, ¿seguís interesado?" } }),
{ workerId: "w1" },
);
expect(runBeforeSend).not.toHaveBeenCalled();
expect(resultado(completeFollowupTurn).kind).toBe("skipped");
});
it("controle: texto pronto de Ajustes → Modelos continua saindo como TEXTO", async () => {
const { d, send } = deps();
await criarHandler(d)(job({ template_id: MODELO_ID }), fakePool({ texto: "oi, tudo bem?" }), { workerId: "w1" });
expect(runBeforeSend).toHaveBeenCalledTimes(1);
expect(runBeforeSend.mock.calls[0]![0].isTemplate).toBeUndefined();
expect(send.mock.calls[0]![0].template).toBeUndefined();
});
});
describe("plano B da mensagem por IA (`fallback_template_id`)", () => {
const PASSO_IA = { prompt_hint: "Retomá la charla", fallback_template_id: MODELO_ID };
it("⭐ janela de 24 h FECHADA: sai o modelo aprovado e a IA não é chamada", async () => {
const { d, send, completeFollowupTurn } = deps();
await criarHandler(d)(job(PASSO_IA), fakePool({ modelo: { status: "APPROVED" }, ultimoInboundHa: 30 }), { workerId: "w1" });
expect(runAgentTurn).not.toHaveBeenCalled();
expect(runBeforeSend.mock.calls[0]![0].isTemplate).toBe(true);
expect(send.mock.calls[0]![0].template).toEqual({ name: "recordatorio_pico", language: "es", values: {} });
expect(resultado(completeFollowupTurn).kind).toBe("sent");
});
it("⭐ janela ABERTA: a IA escreve, como sempre — o plano B não passa na frente", async () => {
const { d } = deps();
await criarHandler(d)(job(PASSO_IA), fakePool({ modelo: { status: "APPROVED" }, ultimoInboundHa: 2 }), { workerId: "w1" });
expect(runAgentTurn).toHaveBeenCalledTimes(1);
expect(runBeforeSend).not.toHaveBeenCalled();
});
it("plano B apontado para texto pronto (legado): segue para a IA, que é o que sempre fez", async () => {
// Texto livre seria recusado pela mesma janela fechada — trocá-lo pela IA
// não perderia nada, e pular a IA por ele seria regressão.
const { d } = deps();
await criarHandler(d)(job(PASSO_IA), fakePool({ texto: "oi", ultimoInboundHa: 30 }), { workerId: "w1" });
expect(runAgentTurn).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,150 @@
/**
* O FLUXO DE SILÊNCIO NÃO FALA POR CIMA DE UM RETORNO AGENDADO.
*
* ## O defeito (medido numa instalação real, 25/09/2026)
*
* A cliente avisou que só compraria no dia 30, quando recebe o salário. O agente
* agendou o retorno (`schedule_followup`) e respondeu "te escrevo no dia 30, sem
* pressa". Uma hora depois o fluxo de silêncio a inscreveu de novo e mandou
* pedido de dados; estavam na fila a oferta que ela já tinha aceitado (dia
* seguinte) e a "última oportunidade" (dia 28). Nada ligava o retorno ao fluxo.
*
* ## O que este arquivo prende
*
* - a varredura de silêncio não inscreve quem tem retorno vivo (e inscreve os demais);
* - a inscrição que já andava fica SEGURADA até a data que a regra devolve, com
* um evento legível no dossiê — sem enfileirar o envio;
* - controle: sem retorno, o mesmo passo enfileira o envio.
*
* ## O que NÃO prova
*
* As consultas reais (`retorno-segura-o-fluxo.ts` sobre o PostgREST) — a régua da
* consulta foi conferida contra um banco de produção ao escrever o conserto, e o
* espelho em SQL puro mora no invariante da varredura (`test:db`).
*/
import { describe, expect, it, vi } from "vitest";
import { runFollowupTick, type AdminClient, type FollowupJobRequest } from "@/lib/followup/engine";
import type { FlowGraph } from "@/lib/followup/graph-schema";
import type { EnrollmentRow } from "@/lib/followup/node-handlers";
import { FOLGA_DEPOIS_DO_RETORNO_MS, reavaliarDepoisDoRetorno } from "@/lib/followup/retorno-segura-o-fluxo";
import { runSilenceSweep, type SilenceSweepDb } from "@/lib/followup/silence-sweep";
const AGORA = new Date("2026-09-25T15:00:00.000Z");
const RETORNO = "2026-09-30T12:00:00.000Z";
describe("varredura de silêncio", () => {
function sweepDb(comRetorno: Set<string>) {
const insert = vi.fn(async () => ({ inserted: true }));
const db: SilenceSweepDb = {
loadActiveSilencePointers: async () => [
{ id: "ptr", organization_id: "org", active_version_id: "v1", threshold_minutes: 60, segments: [] },
],
loadSilentContactIds: async () => ["clara", "outro"],
loadContatosComRetornoVivo: async () => comRetorno,
loadTriggerNode: async () => ({ id: "inicio", pedeAgente: false }),
insertEnrollment: insert,
};
return { db, insert };
}
const gateDb = { loadEnabledPublishedFollowupAgents: async () => [] };
it("⭐ quem tem retorno agendado fica de fora; os demais silenciosos entram", async () => {
const { db, insert } = sweepDb(new Set(["clara"]));
const resumo = await runSilenceSweep({ db, gateDb, clock: () => AGORA });
expect(insert).toHaveBeenCalledTimes(1);
expect(insert).toHaveBeenCalledWith(expect.objectContaining({ contact_id: "outro" }));
expect(resumo.skipped_pending_return).toBe(1);
expect(resumo.enrolled).toBe(1);
});
it("controle: sem retorno nenhum, os dois entram", async () => {
const { db, insert } = sweepDb(new Set());
await runSilenceSweep({ db, gateDb, clock: () => AGORA });
expect(insert).toHaveBeenCalledTimes(2);
});
});
describe("inscrição que já andava", () => {
const GRAFO: FlowGraph = {
nodes: [
{ id: "oferta", type: "action", label: "Oferta", position: { x: 0, y: 0 }, config: { mode: "text", body: "oi" } },
{ id: "fim", type: "end", label: "Fim", position: { x: 0, y: 0 }, config: { outcome: "exhausted" } },
],
edges: [{ id: "oferta-fim", source: "oferta", target: "fim", priority: 0, condition: { type: "always" } }],
};
function loja(seguraAte: string | null) {
const enrollment: EnrollmentRow = {
id: "enr-1",
organization_id: "org",
pointer_id: "ptr",
version_id: "v1",
contact_id: "clara",
conversation_id: null,
current_node_id: "oferta",
status: "active",
next_eval_at: AGORA.toISOString(),
claimed_until: null,
attempts: 0,
max_attempts: 5,
last_error: null,
steps_taken: 3,
outcome: null,
cancel_reason: null,
started_at: AGORA.toISOString(),
completed_at: null,
updated_at: AGORA.toISOString(),
};
const eventos: Array<{ event_type: string; payload: Record<string, unknown> }> = [];
const jobs: FollowupJobRequest[] = [];
const db: AdminClient = {
retornoQueSeguraOFluxo: async () => seguraAte,
async claimDueEnrollments() {
return enrollment.next_eval_at !== null && Date.parse(enrollment.next_eval_at) <= AGORA.getTime()
? [{ ...enrollment }]
: [];
},
loadFlowGraph: async () => GRAFO,
loadLeadFacts: async () => ({ lead_stage: null, tags: [] }),
loadEnrollmentEvents: async () => [],
loadLastInboundBody: async () => null,
async insertEnrollmentEvent(e) {
eventos.push({ event_type: e.event_type, payload: e.payload });
return { inserted: true };
},
async updateEnrollment(_id, _org, patch) {
Object.assign(enrollment, patch);
},
loadFlowPointerName: async () => "Remarketing",
insertDeadInboxItem: async () => undefined,
persistirRespostaFollowup: async () => undefined,
};
const tick = () => runFollowupTick({ db, clock: () => AGORA, enqueueJob: async (j) => void jobs.push(j) });
return { enrollment, eventos, jobs, tick };
}
it("⭐ fica segurada até a data da regra: nada vai para a fila e o dossiê diz por quê", async () => {
const ate = reavaliarDepoisDoRetorno(RETORNO);
const l = loja(ate);
await l.tick();
expect(l.jobs).toHaveLength(0);
expect(l.enrollment.next_eval_at).toBe(ate);
expect(l.enrollment.current_node_id).toBe("oferta");
expect(l.eventos.map((e) => e.event_type)).toEqual(["held_by_return"]);
});
it("controle: sem retorno, o mesmo passo enfileira o envio", async () => {
const l = loja(null);
await l.tick();
expect(l.jobs).toHaveLength(1);
expect(l.eventos.map((e) => e.event_type)).not.toContain("held_by_return");
});
it("a regra devolve um dia depois do retorno — o tempo de a pessoa responder", () => {
expect(Date.parse(reavaliarDepoisDoRetorno(RETORNO)) - Date.parse(RETORNO)).toBe(FOLGA_DEPOIS_DO_RETORNO_MS);
expect(FOLGA_DEPOIS_DO_RETORNO_MS).toBe(24 * 3_600_000);
});
});