Files
DeskcommCRM/tests/unit/catalogo-servido.test.ts
T
Rafael MelgaçoandClaude Opus 5 02d9aceacf refactor(mcp): remove a description morta do catálogo — 51 cópias que ninguém lia
`lib/mcp/tools/catalogo/*.ts` declarava `description` em 51 capacidades; o tipo a
documentava como "Texto tecnico entregue ao MODELO" e sete cabeçalhos repetiam
"`description` fala com o modelo". Nenhum consumidor lia esse campo: a ponte do
turno (`lib/ai/runtime/tools.ts`) monta `def.description`, do HANDLER, e a rota
`/api/v1/mcp/tools` também. As duas fontes divergiam em 48 das 51.

## Por que remover, e não sincronizar

Fonte única mudaria o que 48 tools dizem ao modelo — risco alto, ganho zero. Um
gate de paridade obrigaria manter dois textos sincronizados para sempre, com a
duplicata seguindo lá para ser editada por engano. Remover mata a armadilha na
raiz: não dá para editar o lugar errado se o lugar não existe.

E a remoção SE AUTO-VERIFICA. Tirei o campo do tipo e o `tsc` apontou cada
leitura — prova mais forte que grep.

## O que o typecheck achou: a dívida não era teórica

Um leitor, e o pior possível:
`evidence/ia-360-w4/medicao-vazamento/remedir-com-operador.ts`, o script que mede
vazamento de vocabulário do agente. A função se chama `descreverFerramentas` e o
comentário diz "A ferramenta como o modelo a vê: nome + descrição, que é o que
pode vazar" — e lia `TOOL_CATALOG.description`, exatamente o texto que o modelo
NÃO vê.

NÃO MEDIDO: se isso muda o resultado daquela medição. O `name` é idêntico nas
duas fontes e é o vetor principal de vazamento, então o efeito pode ser nulo,
mas não rodei. Corrigi a fonte para `allTools` e deixei a ressalva no script. Não
reabri a medição arquivada de outra branch.

## O buraco que a remoção expôs

`tests/unit/catalogo-servido.test.ts` testava a junção com FIXTURES
(`description: "faz algo"`), nunca com o catálogo real — nenhum gate garantia que
uma capacidade servida tem descrição. Esvaziar a de um handler passaria calado: a
tela sem explicação e o modelo com uma ferramenta sem contrato.

Caso novo: toda capacidade servida tem descrição não-vazia E ela é IDÊNTICA à do
handler. A segunda metade é a que importa — se reaparecer uma cópia no catálogo e
a junção preferi-la, reprova.

Sabotagens: `description` de um handler vira "" → 1 → 1 (a primeira tentativa não
sabotou nada: escrevi `description: "" ||`, e `"" || "texto"` devolve o texto —
instrumento quebrado, não gate fraco); junção servindo outro texto → 1 → 2.

## Correção de um número que publiquei

A mensagem do commit c56416aa diz "1819 unitários". O real naquele SHA é 1818,
medido depois com `git stash` e a árvore limpa em c56416aa. Rodei a suíte com a
árvore ligeiramente diferente da commitada (antes do `rm` do protótipo e do stage
final) e publiquei como se fosse do commit. Régua daqui em diante: número que sai
em artefato público é medido DEPOIS do stage.

## Saldo

130 linhas removidas, 63 acrescentadas, 9 arquivos. Os 7 cabeçalhos falsos foram
reescritos com o que é verdade e POR QUE o campo não existe mais — para ninguém
"completar" o catálogo de volta. O BRIEFING-ia-360.md acompanhou.

Provado na tela: `/app/ai/agents/<mcp_agent>`, Supabase local (HTML de /login com
2× `127.0.0.1:54321`, 0× `*.supabase.co`), a capacidade segue com rótulo,
explicação, categoria e área — 6/6, zero erro de console.

typecheck 0 · lint 0 errors · 1819 unitários (1818 + 1 caso novo)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkS3mzwtXughmjVC5FCoNo
2026-08-07 12:48:53 -03:00

127 lines
5.1 KiB
TypeScript

/**
* A tela do humano só existe se as duas metades do catálogo se encontrarem.
*
* Metade A: os handlers (`lib/mcp/tools/index.ts`) — o que o servidor sabe
* executar. Metade B: as entradas de `lib/mcp/tools/catalogo/` — o que o humano
* lê para decidir. A rota `/api/v1/mcp/tools` junta as duas por `name`.
*
* Três waves paralelas estão acrescentando capacidades neste catálogo. O modo
* de falhar mais provável não é o typecheck — é alguém declarar a entrada e
* esquecer o handler (ou o contrário). Os dois lados falham em silêncio:
* - handler sem entrada → a tela mostraria a capacidade sem rótulo;
* - entrada sem handler → `tool_ids` aceita o id, o agente é publicado, e o
* runtime descarta a capacidade sem avisar ninguém (`pickToolsFromMcp` faz
* `if (!def) continue`). O humano vê a capacidade ligada na tela e ela
* nunca é oferecida ao modelo.
*/
import { describe, expect, it } from "vitest";
import { allTools } from "@/lib/mcp/tools";
import { TOOL_CATALOG } from "@/lib/mcp/tools/catalog";
import {
juntarCatalogoComHandlers,
type HandlerDeclarado,
} from "@/lib/mcp/tools/catalogo-servido";
const HANDLER_FAKE: HandlerDeclarado = {
name: "crm_fazer_algo",
description: "faz algo",
category: "read",
requiresRole: "agent",
requiresScope: "mcp:read",
};
const ENTRADA_FAKE = {
name: "crm_fazer_algo",
category: "read" as const,
description: "faz algo",
rotulo: "Fazer algo",
explicacao: "Uma explicação com tamanho suficiente para o humano entender o efeito disto.",
oQueToca: "Atendimento",
risco: "seguro" as const,
pacotes: ["atender" as const],
};
describe("juntar as duas metades do catálogo", () => {
it("serve a metade do humano em snake_case, junto com a metade do modelo", () => {
const [servida] = juntarCatalogoComHandlers([HANDLER_FAKE], [ENTRADA_FAKE]);
expect(servida).toEqual({
id: "crm_fazer_algo",
description: "faz algo",
category: "read",
requires_role: "agent",
requires_scope: "mcp:read",
rotulo: "Fazer algo",
explicacao: ENTRADA_FAKE.explicacao,
o_que_toca: "Atendimento",
risco: "seguro",
pacotes: ["atender"],
});
});
it("recusa servir handler sem entrada no catálogo, e diz qual é", () => {
expect(() => juntarCatalogoComHandlers([HANDLER_FAKE], [])).toThrowError(
/crm_fazer_algo/,
);
});
it("não inventa capacidade que não tem handler", () => {
const extra = { ...ENTRADA_FAKE, name: "crm_capacidade_fantasma" };
const servidas = juntarCatalogoComHandlers([HANDLER_FAKE], [ENTRADA_FAKE, extra]);
expect(servidas.map((s) => s.id)).toEqual(["crm_fazer_algo"]);
});
});
describe("catálogo real ↔ handlers reais", () => {
it("tem capacidades (guarda de vacuidade)", () => {
expect(allTools.length).toBeGreaterThan(0);
expect(TOOL_CATALOG.length).toBeGreaterThan(0);
});
it("todo handler tem entrada no catálogo — senão a tela mostra id cru", () => {
const semEntrada = allTools
.map((t) => t.name)
.filter((n) => !TOOL_CATALOG.some((e) => e.name === n));
expect(semEntrada).toEqual([]);
});
it("toda entrada do catálogo tem handler — senão o agente ignora a capacidade ligada", () => {
const semHandler = TOOL_CATALOG.map((e) => e.name).filter(
(n) => !allTools.some((t) => t.name === n),
);
expect(semHandler).toEqual([]);
});
it("a junção do catálogo real não lança e serve todas as capacidades", () => {
const servidas = juntarCatalogoComHandlers(allTools, TOOL_CATALOG);
expect(servidas).toHaveLength(allTools.length);
for (const s of servidas) {
expect(s.rotulo.length, `${s.id} sem rotulo`).toBeGreaterThan(0);
expect(s.pacotes.length, `${s.id} sem pacote`).toBeGreaterThan(0);
}
});
it("toda capacidade servida tem descrição — e ela vem do HANDLER", () => {
// Esta guarda faltava, e a falta ficou visível em 2026-08-07: o catálogo
// tinha uma `description` própria que NENHUM consumidor lia (a ponte do
// turno monta `def.description`, do handler) e que divergia da real em 48
// das 51 capacidades. Ela foi removida; o campo do catálogo não existe mais.
//
// Sem este caso, esvaziar a descrição de um handler passaria calado: a tela
// mostraria a capacidade sem explicação técnica e o modelo receberia uma
// ferramenta sem contrato — dois silêncios de uma vez.
const servidas = juntarCatalogoComHandlers(allTools, TOOL_CATALOG);
const porNome = new Map(allTools.map((t) => [t.name, t.description]));
expect(servidas.length, "nada servido — guarda de vacuidade").toBeGreaterThan(0);
for (const s of servidas) {
expect(s.description.trim().length, `${s.id} servido sem descrição`).toBeGreaterThan(0);
// A IDENTIDADE é o que importa: não basta ter texto, tem de ser o MESMO
// que chega ao modelo. Se um dia reaparecer uma cópia no catálogo e a
// junção passar a preferi-la, este caso reprova.
expect(s.description, `${s.id}: a tela mostra texto diferente do que vai ao modelo`).toBe(
porNome.get(s.id),
);
}
});
});