Skip to content

Commit c6bd41d

Browse files
committed
test(janela): a guarda dedicada do #323 — a que pega o defeito DE DIA
Terceira convergência independente desta rodada: o PR #323 (outra sessão) consertou exatamente as mesmas duas linhas deste PR, sem contato entre os dois. Isso é a evidência mais forte de que o defeito era real. Os consertos de código são equivalentes e ficou o daqui, por não duplicar: ele repete `(deps.clock ?? ((): Date => new Date()))()` nos dois pontos, o que somado à declaração que já existia na linha 1411 daria TRÊS lugares resolvendo o mesmo default. Aqui o `clock` é declarado uma vez, no topo da função. **Mas o teste dele é melhor que o meu conserto, e é ele que entra.** A matriz de sabotagem no cabeçalho do arquivo mostra o que eu não tinha visto: vizinho (3) "fora→adia" "dentro→corre" defeito à noite PEGAM passa (motivo PEGA errado) defeito de dia passam PEGA passa (motivo errado) Os casos que já existiam fixam um instante DENTRO da janela: com o defeito de volta eles só reprovam à noite — uma guarda que dorme das 7h às 22h, que é justamente quando o time trabalha. O caso "fora→adia" é o espelho, e de dia é a ÚNICA coisa entre o defeito e um CI verde. O segundo caso existe pela razão oposta: um gate que adiasse SEMPRE também satisfaria o primeiro, e "adia sempre" é outro jeito de o produto emudecer. Verificado contra o conserto DAQUI (declaração única), não só contra o dele: 3 arquivos, 6 casos, 23:01 BRT ............................ exit 0 sabotado (new Date() cru de volta), mesma hora ............ 5 falhas, 1 passa A previsão foi escrita antes de rodar e bateu: pegaram o "dentro→corre", os três do teto de envios e o `agent-no-credential`; passou só o "fora→adia" — pelo motivo errado, exatamente como a matriz dele prevê para o período noturno. O que este PR tem e o #323 não: o conserto de `agent-no-credential.test.ts`, que nunca injetou relógio. Sem ele o `invariants` segue vermelho fora da janela pela terceira causa — foi o caso que sobrou quando consertar as duas guardas levou de 8 falhas para 1. Crédito do arquivo de teste é da sessão do #323, que fica fechado apontando para cá.
1 parent 3da5587 commit c6bd41d

1 file changed

Lines changed: 286 additions & 0 deletions

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
2+
import pg from "pg";
3+
4+
import type * as InboundTurn from "@/lib/agent-engine/agent/inbound-turn";
5+
import type * as Providers from "@/lib/agent-engine/edge/llm/providers";
6+
import type * as Queue from "@/lib/agent-engine/queue/queue";
7+
import type * as ObsLogger from "@/lib/agent-engine/obs/logger";
8+
9+
/**
10+
* A JANELA DE HORÁRIO É DECIDIDA PELO RELÓGIO INJETADO — nunca pelo de parede.
11+
*
12+
* ─── O defeito que este arquivo existe para impedir ─────────────────────────
13+
*
14+
* `InboundTurnDeps.clock` documenta o próprio uso: "a janela horária do gate
15+
* anti-ban é avaliada nele. Default `() => new Date()`; os testes fixam um
16+
* instante dentro da janela para determinismo."
17+
*
18+
* O gate lia `new Date()` direto, contra esse contrato. O efeito não era um
19+
* teste frouxo: era um CHECK OBRIGATÓRIO (`invariants`) que dependia da hora
20+
* em que alguém abrisse o PR — reprovava entre 22h e 7h, passava no resto do
21+
* dia. Medido em 2026-08-24, mesmo commit, mesma máquina, com o conserto no
22+
* meio: 22:48 BRT sem o conserto → 3 casos de
23+
* `limite-de-envios-por-turno.test.ts` reprovados; 22:50 BRT com ele → verdes.
24+
* As sete rodadas verdes da `main` naquele dia caíram todas entre 09:58 e
25+
* 15:14 BRT: o defeito viveu escondido no horário comercial de quem trabalha
26+
* neste repo.
27+
*
28+
* ─── Por que um arquivo NOVO, e não mais casos no arquivo vizinho ───────────
29+
*
30+
* `tests/invariants/**` é congelado (`loop/hooks/freeze-invariants.sh`):
31+
* acrescentar arquivo é permitido, modificar um existente é bloqueado. A regra
32+
* está certa e é ela que impede o movimento "invariante incômodo → editar
33+
* invariante". Acrescentar é o caminho sancionado — e aqui ele também é a
34+
* separação certa: o vizinho mede o TETO DE ENVIOS por turno e fixa o relógio
35+
* só para o horário não atrapalhar; este mede o HORÁRIO em si.
36+
*
37+
* ─── Por que DOIS casos, e não só o que pegaria o defeito ───────────────────
38+
*
39+
* Os casos do vizinho injetam um instante DENTRO da janela. Com o defeito de
40+
* volta, eles só reprovam À NOITE — uma guarda que dorme das 7h às 22h, que é
41+
* justamente quando o time trabalha. O primeiro caso daqui é o espelho: injeta
42+
* um instante FORA e exige o adiamento, então reprova DE DIA. Medido,
43+
* sabotando o conserto nas duas condições:
44+
*
45+
* vizinho (3) "fora→adia" (aqui) "dentro→corre" (aqui)
46+
* defeito à noite PEGAM passa (motivo PEGA
47+
* errado)
48+
* defeito de dia passam PEGA passa
49+
* (motivo
50+
* errado)
51+
*
52+
* De dia, o primeiro caso daqui é a ÚNICA coisa entre o defeito e um CI verde.
53+
* O segundo existe pela razão oposta: um gate que adiasse SEMPRE também
54+
* satisfaria o primeiro, e "adia sempre" é outro jeito de o produto emudecer.
55+
*
56+
* Harness igual ao do vizinho: handler real, modelo fake, canal que CAPTURA em
57+
* vez de enviar, `sleep` no-op. Os ids são PRÓPRIOS: o `setupFile` recria o
58+
* banco por arquivo, mas ids distintos deixam o log legível quando os dois
59+
* arquivos aparecem na mesma corrida.
60+
*/
61+
62+
const container = process.env.TEST_DB_CONTAINER;
63+
if (!container) {
64+
throw new Error("TEST_DB_CONTAINER not set — rode via `pnpm test:db` (scripts/test-db.sh)");
65+
}
66+
67+
process.env.NEXT_PUBLIC_SUPABASE_URL ??= "https://placeholder.supabase.co";
68+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ??= "placeholder-anon";
69+
process.env.SUPABASE_SERVICE_ROLE_KEY ??= "placeholder-service";
70+
71+
const PORT = Number(process.env.TEST_DB_PORT ?? 54329);
72+
const pool = new pg.Pool({
73+
connectionString: `postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`,
74+
max: 2,
75+
});
76+
77+
const ORG = "dddddddd-0000-4000-8000-0000000000a1";
78+
const CONTACT = "dddddddd-0000-4000-8000-0000000000a2";
79+
const SESSION = "dddddddd-0000-4000-8000-0000000000a3";
80+
const CONV = "dddddddd-0000-4000-8000-0000000000a4";
81+
const MSG = "dddddddd-0000-4000-8000-0000000000a5";
82+
const CRM_EVENT = "dddddddd-0000-4000-8000-0000000000a6";
83+
84+
/** Terça, 15h BRT — dentro da janela anti-ban padrão (7h–22h). */
85+
const DENTRO_DA_JANELA = new Date("2026-07-28T18:00:00Z");
86+
/** Terça, 3h BRT — fora dela, com folga dos dois lados. */
87+
const FORA_DA_JANELA = new Date("2026-07-28T06:00:00Z");
88+
89+
interface EnvioCapturado {
90+
body: string;
91+
}
92+
93+
type Modules = {
94+
createInboundTurnHandler: typeof InboundTurn.createInboundTurnHandler;
95+
queue: typeof Queue;
96+
createLogger: typeof ObsLogger.createLogger;
97+
createFakeRegistry: typeof Providers.createFakeRegistry;
98+
};
99+
let m: Modules;
100+
101+
let enviados: EnvioCapturado[] = [];
102+
103+
const CHECKPOINT = JSON.stringify({
104+
commitments: [],
105+
objections: [],
106+
next_action: null,
107+
rolling_summary: "turno de teste",
108+
});
109+
110+
const USO = {
111+
inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
112+
outputTokens: { total: 1, text: 1, reasoning: 0 },
113+
};
114+
115+
/**
116+
* Modelo fake que manda UMA mensagem e encerra. `rotulo` é único por caso: os
117+
* dois compartilham a mesma conversa, e o gate `spinning` veta corpo repetido
118+
* entre turnos — sem o rótulo, o segundo caso seria bloqueado por um motivo
119+
* que não é o deste arquivo.
120+
*/
121+
function modeloQueManda(rotulo: string) {
122+
let mandou = false;
123+
return async () => {
124+
if (!mandou) {
125+
mandou = true;
126+
return {
127+
content: [
128+
{
129+
type: "tool-call" as const,
130+
toolCallId: "c1",
131+
toolName: "send_message",
132+
input: JSON.stringify({ body: `oi, tudo bem? (${rotulo})` }),
133+
},
134+
],
135+
finishReason: { unified: "tool-calls" as const, raw: undefined },
136+
usage: USO,
137+
warnings: [],
138+
};
139+
}
140+
return {
141+
content: [{ type: "text" as const, text: CHECKPOINT }],
142+
finishReason: { unified: "stop" as const, raw: undefined },
143+
usage: USO,
144+
warnings: [],
145+
};
146+
};
147+
}
148+
149+
function montaHandler(doGenerate: unknown, instante: Date) {
150+
return m.createInboundTurnHandler({
151+
crmCfg: { supabase: {} as never },
152+
llmCfg: { anthropicApiKey: "fake" } as never,
153+
knobs: {
154+
historyLimit: 10,
155+
maxContextTokens: 1000,
156+
notesIndexMaxTokens: 500,
157+
maxSteps: 12,
158+
queuedRetryDelayMs: 1000,
159+
breaker: {
160+
exactFailureWarn: 2,
161+
exactFailureBlock: 5,
162+
sameToolFailureWarn: 3,
163+
sameToolFailureHalt: 8,
164+
noProgressWarn: 3,
165+
noProgressBlock: 5,
166+
},
167+
},
168+
log: m.createLogger(),
169+
registry: m.createFakeRegistry(doGenerate as never),
170+
channel: () =>
171+
({
172+
channel: "captura",
173+
send: async (i: EnvioCapturado) => {
174+
enviados.push(i);
175+
return {
176+
kind: "sent" as const,
177+
idempotencyKey: `k${enviados.length}`,
178+
messageId: `m${enviados.length}`,
179+
};
180+
},
181+
sessionHealth: async () => ({ healthy: true, status: "WORKING" }),
182+
capabilities: () => ({ freeform: true, media: true, audio: true }),
183+
costPerMessage: () => ({ currency: "BRL", cents: 0 }),
184+
}) as never,
185+
// O ponto do arquivo: é ESTE instante que decide a janela, e não a hora em
186+
// que a suíte por acaso rodou.
187+
clock: () => instante,
188+
sleep: async () => {},
189+
});
190+
}
191+
192+
async function rodaTurno(handler: ReturnType<typeof montaHandler>): Promise<Error | null> {
193+
await pool.query("update job_queue set status = 'done' where status = 'pending'");
194+
const { job } = await m.queue.enqueueJob(pool, ORG, {
195+
kind: "inbound_turn",
196+
leadId: CONTACT,
197+
payload: {
198+
conversation_id: CONV,
199+
contact_id: CONTACT,
200+
channel_session_id: SESSION,
201+
inbound_message_id: MSG,
202+
crm_event_id: CRM_EVENT,
203+
},
204+
maxAttempts: 1,
205+
});
206+
const [claimed] = await m.queue.claimJobs(pool, { workerId: "janela", maxConcurrency: 1 });
207+
expect(claimed?.id).toBe(job.id);
208+
try {
209+
await handler(claimed!, pool, { workerId: "janela" });
210+
await m.queue.completeJob(pool, claimed!.id, "janela");
211+
return null;
212+
} catch (err) {
213+
await m.queue.failJob(pool, claimed!.id, "janela", err);
214+
return err as Error;
215+
}
216+
}
217+
218+
beforeAll(async () => {
219+
m = {
220+
createInboundTurnHandler: (await import("@/lib/agent-engine/agent/inbound-turn"))
221+
.createInboundTurnHandler,
222+
queue: await import("@/lib/agent-engine/queue/queue"),
223+
createLogger: (await import("@/lib/agent-engine/obs/logger")).createLogger,
224+
createFakeRegistry: (await import("@/lib/agent-engine/edge/llm/providers")).createFakeRegistry,
225+
};
226+
227+
await pool.query(
228+
`insert into organizations (id, slug, legal_name, display_name)
229+
values ($1,'janela-relogio','Janela Relogio','Janela Relogio') on conflict (id) do nothing`,
230+
[ORG],
231+
);
232+
await pool.query(
233+
`insert into contacts (id, organization_id, name, phone_number)
234+
values ($1,$2,'Lead da Janela','+5511900000777') on conflict (id) do nothing`,
235+
[CONTACT, ORG],
236+
);
237+
await pool.query(
238+
`insert into channel_sessions (id, organization_id, waha_session_name, status, webhook_secret_encrypted)
239+
values ($1,$2,'janela-relogio-session','WORKING','\\x00'::bytea) on conflict (id) do nothing`,
240+
[SESSION, ORG],
241+
);
242+
await pool.query(
243+
`insert into conversations (id, organization_id, contact_id, channel_session_id, status, is_group)
244+
values ($1,$2,$3,$4,'ai_handling',false) on conflict (id) do nothing`,
245+
[CONV, ORG, CONTACT, SESSION],
246+
);
247+
await pool.query(
248+
`insert into messages (id, organization_id, conversation_id, channel_session_id, contact_id,
249+
type, direction, status, body, sent_via, sent_at)
250+
values ($1,$2,$3,$4,$5,'text','inbound','delivered','Oi','external_device', now())
251+
on conflict (id) do nothing`,
252+
[MSG, ORG, CONV, SESSION, CONTACT],
253+
);
254+
await pool.query(
255+
`with v as (
256+
insert into playbook_versions (organization_id, layer, content)
257+
select null, 'platform', E'## Identidade\nAssistente de teste.'
258+
where not exists (select 1 from playbook_pointers where organization_id is null and layer = 'platform')
259+
returning id)
260+
insert into playbook_pointers (organization_id, layer, version_id)
261+
select null, 'platform', id from v`,
262+
);
263+
});
264+
265+
beforeEach(() => {
266+
enviados = [];
267+
});
268+
269+
describe("a janela de horário é decidida pelo relógio injetado", () => {
270+
it("relógio FORA da janela: o turno é adiado, não importa a hora real", async () => {
271+
const erro = await rodaTurno(montaHandler(modeloQueManda("noturno"), FORA_DA_JANELA));
272+
273+
// Adiado, não gasto: `JobSettledError` é o contrato de "o run já dispôs do
274+
// job". Quem escreveu às 3h é atendido às 7h — e nada sai agora.
275+
expect(erro).not.toBeNull();
276+
expect(String(erro?.message)).toMatch(/fora da janela anti-ban/);
277+
expect(enviados).toHaveLength(0);
278+
});
279+
280+
it("relógio DENTRO da janela: o turno corre — o gate não vira muro", async () => {
281+
const erro = await rodaTurno(montaHandler(modeloQueManda("diurno"), DENTRO_DA_JANELA));
282+
283+
expect(erro).toBeNull();
284+
expect(enviados).toHaveLength(1);
285+
});
286+
});

0 commit comments

Comments
 (0)