Documento vivo. Alimentado a cada avanço, cada bug encontrado, cada coisa deixada para trás. Toda afirmação declara o SHA curto de onde foi medida. Número sem SHA não compara.
Contrato:
docs/specs/16-spec-tres-papeis-do-agente.mdDoutrina:docs/doctrine/separacao-fala-e-operacao.mdBranch:feat/tres-papeis-do-agente· Base:origin/main=0a85d251Worktree dedicado:/Users/rafaelmelgaco/DeskcommCRM-tres-papeis— a árvore principal foi trocada por outra sessão duas vezes no meio do trabalho, e o worktree acabou com a disputa. Ele não tem.env.local, e isso é uma proteção, não um detalhe (ver Passo 5 abaixo).
| # | passo | estado | prova |
|---|---|---|---|
| 1 | Gate de vazamento no before-send |
✅ feito (veio da main) | 30,0% / 0,0% medidos, 18 turnos |
| 2 | Contrato da declaração | ✅ feito e167b362 |
16 testes · 5 sabotagens · test:db verde |
| 3 | Projeção do contexto | ✅ feito (este commit) | 18 testes, 3 contra turnos REAIS |
| 4 | Operador por evento | ✅ feito 35c88346 |
12 testes · 3 sabotagens com contagem prevista · test:db verde |
| 5 | UI dos três papéis + Testar no caminho real |
✅ feito 9b26ad76·2b85047e·1b9649c6·1e19ef4a |
29 testes · e2e 7/7 pela tela · 4 sabotagens no Testar |
| 6 | Tirar tools de escrita do Conversador | ✅ feito 16386d0e+91f31c52 |
21 testes · medido: 30,0% → 10,0% com ferramentas executadas |
| medida | valor |
|---|---|
| Vazamento com prompt de OPERADOR | 30,0% (3 em 10) |
| Vazamento com prompt de ATENDIMENTO | 0,0% (0 em 8) |
| Tools no catálogo MCP | 51 (30 leitura, 20 escrita, 1 handoff) |
| Tools nativas do engine | 12 |
| Teto de tools por agente | 20 → até 32 num prompt só |
Gates de before_send |
10 (cadeia v6) |
pnpm vitest run |
2603 passed / 270 arquivos |
pnpm lint |
0 erros, 189 warnings |
O que mudou. lead_checkpoints ganhou declaracao jsonb; a declaração viaja na chamada de
fechamento que já existe (custo zero), não numa tool que o modelo poderia esquecer.
A decisão que carrega o desenho: undefined/NULL (não declarou) ≠ {nada_a_declarar:true}
(avaliou e não havia nada). Um default otimista faria "esqueceu" parecer "não havia nada".
Consumidor imediato: buildHandoffSummary. Sem ele a declaração seria evento sem consumer.
Evidência: 16 testes · suíte 2603 · typecheck limpo · lint 0 erros / 189 warnings (mesmo número
da main, medido com stash) · test:db verde (68 arquivos, 462 passed).
Sabotagem — 5 defeitos, 5 reprovações no teste certo:
| sabotagem | resultado |
|---|---|
.default({}) no lugar do .optional() |
2 failed ✅ |
| declaração sai da instrução de fechamento | 1 failed ✅ |
| handoff para de ler a declaração | 2 failed ✅ |
.strict() vira .passthrough() |
1 failed ✅ |
| vocabulário interno entra na instrução | 1 failed ✅ |
O que mudou. lib/agent-engine/agent/projecao.ts: allowlist do que o Conversador vê. Ligada em
três superfícies — abertura do ritual, releitura via get_lead_context, e retorno de
search_knowledge.
O interruptor. Arma quando nenhuma ferramenta de catálogo entrou no turno — é exatamente onde os ids do contexto não têm uso (as MCP os recebem como argumento; as nativas resolvem pelo closure). Sem env novo, sem botão para o self-hoster errar. No passo 6 ela passa a valer sozinha.
Evidência: 18 testes · suíte 2621 passed / 271 arquivos · typecheck limpo · lint 0 erros.
Três casos medem contra os turnos REAIS de gpt-5.6-terra versionados em evidence/, com controle
positivo em cada um e falha declarada se a evidência sumir.
🐛 search_knowledge vazava dois UUIDs por resultado — corrigido aqui.
chunk_id e knowledge_source_id iam crus ao modelo em toda busca com RAG. O modelo não tem o
que fazer com eles (nenhuma ferramenta os aceita como argumento); as citações são montadas pelo
código. Era fonte silenciosa do UUID cru que a medição viu chegar à tela do cliente. Não estava
previsto no plano — apareceu ao procurar consumidor para a projeção.
🐛 Bug que quase entrei, registrado porque o teste dele fica. Eu aplicava a tradução de erro ao corpo das mensagens. "Meu site tem um webhook quebrado" — frase legítima de quem vende integração — viraria "não consegui concluir essa verificação", e o agente responderia a uma pergunta que ninguém fez. A fala de quem está do outro lado é o único dado que nunca se corrige. Coberto por teste de regressão.
🔧 Cabeçalho do bloco de estado imprimia lead_state — nome de tabela no prompt, vazamento
gratuito pela porta 2 sem nem precisar de uma ferramenta. Corrigido.
🐛 Bug MEU que entrou e a sabotagem pegou — a859a735.
traduzirErroCru era aplicada a toda string do retorno de ferramenta. O detector considera
"webhook" vazamento (com razão, na saída), e a base de conhecimento de qualquer empresa que venda
integração fala webhook: o content do search_knowledge viraria "não consegui concluir essa
verificação" linha após linha. A projeção destruiria o RAG do tenant para proteger contra um
vazamento que o gate de saída já cobre.
É a mesma falha da fala do cliente, numa irmã que não se parece por fora: as duas nascem de aplicar num texto de terceiros um filtro desenhado para texto do sistema. Procurei a classe depois de pegar a primeira instância e não achei esta.
Como o teste passava sem medir: o detector pega UUID (categoria erro_cru), então os casos de
evidência passavam pela tradução mesmo com a remoção de chave desligada — dois mecanismos
redundantes, e eu atribuía o resultado ao errado. O sinal foi a sabotagem S2 derrubar 1 caso de 3.
Depois da correção: S2 derruba 3 de 3.
| sabotagem | resultado |
|---|---|
| allowlist vira spread (campo novo passa) | 2 failed ✅ |
chaveDeIdentificador desligada |
3 failed ✅ (era 1 antes da correção) |
| tradução devolve texto cru (falha aberta) | 1 failed ✅ |
| interruptor arma sempre | 1 failed ✅ |
| tradução volta a pisar na fala do cliente | 1 failed ✅ |
chaveDeErro aceita tudo (volta o bug do RAG) |
1 failed ✅ |
chaveDeErro rejeita tudo (desarma a defesa) |
2 failed ✅ |
O que mudou. job_queue aceita operator_turn; o job é enfileirado pelo runtime ao fim do
turno, logo depois de o checkpoint existir, com sourceEventId = job do Conversador (retry não gera
um segundo Operador). Handler em operator-turn.ts, registrado no worker. ai_agent_versions ganhou
operator_enabled (default false) e operator_model (null = herda).
Onde o passo 2 se paga. O curto-circuito usa a distinção que construímos lá:
| declaração | decisão | por quê |
|---|---|---|
nada_a_declarar: true |
não chama modelo | quem avaliou estava lá, com todo o contexto |
ausente (null) |
roda | ninguém avaliou — é aí que promessa fica órfã |
Há um teste afirmando que os dois estados levam a decisões opostas. Se alguém colapsar a distinção, ele vermelha.
Evidência: 7 testes de decisão + 5 invariantes de schema · suíte 2630 passed / 272 arquivos ·
test:db verde (69 arquivos, 467 passed) · typecheck limpo · lint 0 erros / 189 warnings.
| sabotagem | previsão | resultado |
|---|---|---|
| ausente tratado como vazio | ≥2 | 2 failed ✅ |
| papel desligado passa a rodar | 2 | 2 failed ✅ |
| infere "vazio" por listas em vez da afirmação | 1 | 1 failed ✅ |
Prever a contagem virou regra depois do passo 3, onde uma sabotagem derrubou 1 caso quando devia derrubar 3 — e o verde dos outros 2 vinha de um mecanismo redundante que era, ele próprio, um bug.
- Segundo bloco de
job_queue_kind_checkno baseline. Reconstruir a mesma constraint em N blocos quebra oupdate.shde todo clone com vocabulário posterior. Eu tinha aplicado essa lição corretamente aoagent_inbox_itemsminutos antes, e a irmã passou batido — o padrão pego numa ocorrência dá álibi às outras. Pego porbaseline-constraint-reconstruida.test.ts. - Kind novo divergindo entre banco e TypeScript — pego por
vocabulario-banco-x-typescript.test.ts.
Os dois viraram verde depois de corrigidos, com run limpo. Registro aqui porque a saída limpa sem esta nota creditaria ao meu rigor o que foi mérito da catraca.
Fatia 1 — o Operador ganha mão. Lê operator_tool_ids (coluna própria, migration 0112),
monta o toolset pela ponte MCP e roda turno de modelo com briefing e custo próprios
(purpose: 'operator_turn' — sem isso "quanto custa ligar o papel?" não teria resposta).
Sem canal, estruturalmente: send_message é nativa do engine e não existe no catálogo, então
não há id que a ligue; crm_send_whatsapp_message está em BLOCKED_TOOL_IDS, agora exportado
para ser asserível — garantia que nenhum teste consegue ler é garantia que ninguém percebe quando
some.
Fatia 2 — a tela. Navegação por papel dentro do mesmo form (um rascunho, um save). A régua é dizer a consequência: com o papel desligado, a tela explica o que continua acontecendo (o básico é registrado sozinho) e o que para. Sem a primeira frase o usuário conclui que desligar deixa o sistema cego, e liga por medo em vez de escolha.
Evidência: 16 testes novos (8 de motor, 8 de componente) · suíte unit 1459 passed / 154
arquivos · test:db verde (69 arquivos, 467 passed) · typecheck limpo · lint 0 erros ·
4 sabotagens na tela, 4 reprovações, contagem prevista em cada uma.
.env.local aponta NEXT_PUBLIC_SUPABASE_URL para …porysaiysiztn.supabase.co — a nuvem, não
o Supabase local (que está de pé em 127.0.0.1:54321). O playwright.config.ts sobe o app com
next start, que carrega .env.local.
Consequência: rodar pnpm test:e2e nesta máquina, hoje, cria agentes, versões e conversas de
teste no banco de produção. Não rodei.
Isto não é específico deste épico: vale para qualquer sessão que rode e2e nesta máquina agora.
Saídas possíveis (decisão do Rafael, é config dele):
.env.e2eapontando para o local, carregado pelowebServerdo Playwright — conserta para todo mundo e é o que a doutrina de QA Visual pressupõe;- apontar
.env.localpara o local enquanto se testa (manual, esquecível — foi o que já mordeu antes, verfeedback_env_local_aponta_para_remoto); - rodar a prova numa VPS descartável.
Enquanto isso, o que está provado da tela é comportamento de componente, não jornada de usuário. São coisas diferentes e a diferença está declarada.
Descoberto ao destravar a prova de tela. Não é específico deste épico.
.env.local de um checkout de trabalho aponta para a nuvem, e o Playwright sobe o app com
next start, que o carrega. A suíte criava organizações, usuários e agentes de teste no banco real
— passando verde. A org e2e-test-org está na produção desde 2026-04-29: não chegou lá por
acidente de uma sessão, chegou porque era o comportamento normal.
Isolar o webServer não bastava — e essa foi a descoberta que importa. Os scripts de seed leem
.env.local direto do disco, ignorando process.env, e as specs os chamam sozinhas no meio do
teste. O sintoma que denunciou: o factor TOTP em .e2e-creds.json não existia no banco local,
porque tinha sido criado na nuvem. 93 arquivos têm esse padrão.
| # | camada | o que impede |
|---|---|---|
| 1 | .env.e2e + injeção no webServer, com guard que recusa se o arquivo faltar |
o servidor falar com a nuvem |
| 2 | pnpm e2e:build |
as NEXT_PUBLIC_* de produção ficarem dentro do bundle do browser |
| 3 | scripts/lib/env-de-teste.ts — process.env vence, e todo seed anuncia o destino |
os seeds escaparem do isolamento |
A camada 2 existe porque node --env-file não serve: o next build cria Workers e o Node
recusa propagar a flag (ERR_WORKER_INVALID_EXEC_ARGV). O script prova as duas direções — host
de produção ausente do bundle e host local presente. Sem o controle positivo, "não achei
produção" pode ser apenas um grep que não acha nada.
Todos os scripts/seed-e2e-*.ts usam credenciaisSupabaseDeTeste() e anunciam o destino.
Dois já tinham a precedência certa e só ganharam o anúncio (capacidades lia o arquivo como
fallback, invite já usava process.env puro) — mas o capacidades estourava com ENOENT num
worktree sem .env.local, justo onde a ausência é a proteção.
Gate: tests/unit/seed-nao-le-env-local-do-disco.test.ts. Sem ele, o 17º seed nasce copiando o
mais próximo. Escrever em produção não é reparável por code review depois do fato.
A primeira regex do gate não media nada. Era
/readFileSync\s*\(\s*(?:path\.join\([^)]*)?…\.env\.local/, e o[^)]*parava no)deprocess.cwd()— no meio depath.join(process.cwd(), ".env.local"). A alternativa nunca casava. Descoberto por sabotagem: reintroduzir a leitura num seed não vermelhou nada. A versão atual procura o literal em vez de modelar a sintaxe — regex que entende estrutura erra calada.
Prova: os 16 seeds executados de verdade, 16/16 anunciando escrevendo em LOCAL. Typecheck
sozinho não bastava: ele já deixou passar um env is not defined nesta mesma série.
tests/e2e/agente-papeis-operador.spec.ts — 7/7 passed em 1,8 min contra o Supabase local.
O caso central é salvar → RECARREGAR → conferir. Teste de componente mede o componente; entre a
escolha do usuário e o banco existem o formulário, a server action, o Zod, o VERSION_COLUMNS de
seis arquivos e o SELECT que relê — cada um pode perder um campo sem quebrar unidade nenhuma.
Sabotagem que prova o teste: removi operator_enabled do SELECT de um dos seis arquivos
(page.tsx) — o defeito exato que já aconteceu com cases_enabled e que eu repeti nesta série. O
spec reprovou, e a mensagem apontou o culpado:
o papel voltou desligado depois do refresh — algum ponto do caminho não carrega
operator_enabled(ver agent-version-columns-drift.test.ts)
🐛 default_agent_id do seed é um rag_bot, e a tela de papéis é do mcp_agent. Apontar para
ele fazia o spec falhar como se a aba não existisse. O spec passou a semear a própria precondição
— depender de um mcp_agent que outra spec deixou é depender da ordem alfabética dos arquivos.
🐛 Um login por teste estourava o teto do produto. 7 casos = 7 logins, contra um teto de 60/IP a cada 5 min — o mesmo que protege a conta de um cliente real. A bateria caía no 3º caso e a falha aparecia como "campo de MFA não apareceu", parecendo defeito de produto onde havia limite de ambiente. Agora é um login numa página compartilhada: 7/7 em 1,8 min, contra 6/7 em 7,5 min.
Ele mostrava uma resposta que nenhum gate examinou — a rota chama o runtime @deprecated, que
não importa runBeforeSend, e a cadeia inteira está ausente do build do app Next.
O conserto não finge cobertura. Seis dos dez gates dependem de estado que só existe no turno real (contadores de pacing, janela de cópias, carimbo da última inbound, ledger, base legal). Fabricar esse estado daria um veredito inventado — pior que veredito nenhum, porque tem aparência de prova. Então a avaliação cobre o que é decidível só com o texto (o gate de vocabulário interno, o mais caro pela medição) e declara os outros nove na tela, com o motivo de cada um.
Os motivos são escritos para o dono do negócio: "depende de quando o contato falou com você pela última vez", não "depende do send_ledger". Há teste varrendo jargão nessa lista — repetir na tela de configuração o vocabulário que a spec 16 existe para matar seria irônico.
A lista é amarrada a BEFORE_SEND_GATES: acrescentar um gate à cadeia vermelha o teste e força
decidir de que lado ele cai. O caminho do stub também avalia — um caminho sem avaliação voltaria a
ser o verde que não olhou para nada, só que mais difícil de notar.
| sabotagem | previsão | resultado |
|---|---|---|
passou vira sempre true |
1 | 1 failed ✅ |
| some a lista de não-avaliados | ≥3 | 3 failed ✅ |
| jargão entra no motivo mostrado ao usuário | 1 | 1 failed ✅ |
| um gate some da lista, como se fosse avaliado | 1 | 1 failed ✅ |
Este worktree não tem .env.local. Um script que o leia do disco falha alto (ENOENT) em
vez de escrever na nuvem. O isolamento deixa de depender de disciplina e passa a depender da
ausência do arquivo — que é o tipo de garantia que não esquece.
Prova: agente-novo-e-uso 5/5 passed contra o banco LOCAL (era 0/5, por MFA — o factor
do banco não batia com o do arquivo, justamente porque o seed ia para a nuvem).
O gate é rede: barra na saída e ensina. A cura é o Conversador nunca ter visto o vocabulário — o modelo não repete o nome de uma ferramenta que não está no contexto dele.
A regra que impede o buraco: a capacidade só muda de dono quando o novo dono existe. A
nativa sai do Conversador apenas se o Operador estiver ligado E tiver o equivalente marcado.
Ligar o papel e esquecer de marcar crm_move_lead_stage tiraria o avanço do funil sem dar a
ninguém — e o funil pararia em silêncio, que é o modo de falha que este épico combate.
A instrução sai do prompt junto. Remover a ferramenta e manter a linha "Marque-o com update_lead_state" seria o pior dos dois mundos: o modelo chamaria o que não existe e o nome continuaria no contexto — a cura não teria acontecido. Há teste só para isso.
| nativa | destino | por quê |
|---|---|---|
update_lead_state |
✅ sai | crm_move_lead_stage / crm_update_lead cobrem |
schedule_followup |
✅ sai | crm_schedule_followup cobre |
save_lead_note |
❌ fica | sem equivalente no catálogo |
open_human_case |
❌ fica | sem equivalente no catálogo |
provide_case_update |
❌ fica | sem equivalente no catálogo |
request_human_handoff |
❌ fica | existe no catálogo mas está em BLOCKED_TOOL_IDS; e é decisão da CONVERSA, com efeito imediato |
A tabela de equivalência é verificada nos dois sentidos: toda nativa listada existe em
AGENT_TOOL_DEFS, todo equivalente existe em TOOL_CATALOG. Um typo em qualquer lado faria o corte
nunca acontecer, silenciosamente.
Zero mudança para quem não ligou o papel — operator_enabled nasce false.
Relatório: RELATORIO-passo6.md.
Mesmo modelo da linha de base (gpt-5.6-terra), 10 cenários × 3 configurações.
| configuração | taxa |
|---|---|
| A · CONTROLE (linha de base replicada) | 10,0% |
| B · passo 6 como entregue | 10,0% |
| C · cura completa (operação sai) | 10,0% |
As três iguais, mesmo cenário vazando. E em C — sem nenhuma ferramenta de webhook no contexto —
o modelo ainda disse webhook: parte do vocabulário vem do MODELO, não do contexto. Ausência de
ferramenta reduz superfície; não elimina vazamento.
O controle não reproduz os 30%, e a causa está identificada: o instrumento não executa as ferramentas, então não exercita a porta 3 (dado retornado) — que era 2 dos 3 vazamentos originais. Ele mede a porta 1/2, e nela achou exatamente 1 de 10, coerente com o único vazamento por nome da linha de base. Calibração parcial e explicada, não aleatória.
O que isso confirma: o prompt é a variável dominante (30% → 0% na linha de base foi troca de prompt, mesmas ferramentas). O valor do épico não é "zero por ausência" — é o dono do negócio deixar de precisar escrever "atenda E organize" no prompt do Conversador, porque agora existe um papel para isso. O gate segue como rede para o resíduo que vem do próprio modelo.
O instrumento reportou
0,0%nas três configurações na primeira execução — parecia sucesso perfeito. As 30 chamadas tinham voltado HTTP 400, os textos vieram vazios, e o detector não acha nada em texto vazio. O erro estava capturado por linha; o resumo só imprimia a taxa. Corrigido: o script recusa calcular taxa quando há turnos que não rodaram.
A spec define o sinal como "o vazamento medido em 30% vai a zero por ausência, não por filtro".
Isso exige re-rodar a medição (18 turnos, gpt-5.6-terra) com o Operador ligado e comparar. Não
foi feito: a medição original precisa de chave de LLM com crédito e de um turno real com worker.
Até lá, o que está provado é o mecanismo (a ferramenta e o nome somem do contexto), não o efeito (a taxa cair). São coisas diferentes, e a diferença está declarada.
| # | o quê | por quê ficou | onde fecha |
|---|---|---|---|
| 1 | Conteúdo do bloco de estado (stage: 'qualifying') segue cru |
update_lead_state precisa dele para marcar o próximo estágio |
passo 6, junto com a ferramenta |
| 2 | Retorno de tool MCP não é projetado | remover ids quebraria a operação; hoje cobre o gate de saída | passo 6 (Conversador sem elas) |
| 3 | projeta é opcional no tipo de buildOpening |
tests/invariants/** é congelado por hook; obrigatório forçaria editar invariante |
quando houver flip legítimo |
| 4 | Botão Testar não exercita guardrail nenhum | runAgent deprecated não importa runBeforeSend |
passo 5 |
| 5 | 2ª camada do fail-safe de vazamento nunca exercitada | o modelo consertou de primeira na observação | — |
| 6 | Inbound sem turno próprio: 5 mensagens → 4 jobs, nenhum deduped |
fora do escopo da medição que o achou | a investigar |
| 7 | Tudo medido só em gpt-5.6-terra |
chave Anthropic da máquina sem crédito | — |
| 8 | O Operador ainda não tem MÃO | o passo 4 entrega o CANAL (job, disparo, decisão, config); as ferramentas de escrita entram junto com a UI que as configura | passo 5 |
| 9 | Nenhum turno de produção observado com worker real | a projeção e o Operador estão provados por unit + payload real, não por execução ponta a ponta | passo 5, junto com a prova de tela |
| 10 | Testar sem guardrail |
✅ feito — avalia o texto e declara os 9 gates que não consegue avaliar | — |
| 11 | ✅ feito — agente-papeis-operador.spec.ts, 7/7 |
— | |
| 12 | .env.local do disco |
✅ migradas — 0 arquivos leem do disco; gate cobre scripts/ + tests/ + tests/e2e/ (188 casos) |
— |
| 14 | Taxa total (com ferramentas EXECUTADAS) — o que calibraria o controle | exige MCP real contra dados de verdade: worker + banco | quando houver ambiente |
| 15 | 3 nativas sem equivalente no catálogo (save_lead_note, open_human_case, provide_case_update) |
expor equivalentes é decisão de produto, não de refactor | quando o catálogo crescer |
| 13 | ✅ removidas em 2026-08-06 — 2 orgs, 5 usuários, 57 contatos, 144 mensagens, 56 leads, 18 agentes | — | |
| 16 | 🐛 Não é possível deletar uma organização | o trigger de audit insere em api_audit_log referenciando a org que o cascade acabou de apagar → FK violation |
a investigar |
| 17 | 🐛 Não é possível deletar um agente que é dono de lead | o SET NULL em crm_leads.owner_agent_id viola crm_leads_owner_kind_coherence (owner_kind='ai' sem agente) |
a investigar |
- Testar a cada avanço, não no fim. Avanço sem teste não é avanço.
- Verde de primeira não prova nada — sabotar cada afirmação e confirmar a reprovação.
- Evidência real acima de fixture. Fixture mede a imaginação de quem a escreveu — que é a que já falhou, senão o defeito não existiria.
- Commitar antes de sabotar.
git checkout <arquivo>leva junto o trabalho não commitado (aconteceu, custou 4 edições). - Não driblar catraca do repo. O hook de invariantes bloqueou um commit; a saída foi mudar o desenho, não exportar a variável de escape.