mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
`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 commitc56416aadiz "1819 unitários". O real naquele SHA é 1818, medido depois com `git stash` e a árvore limpa emc56416aa. 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
127 lines
5.1 KiB
TypeScript
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),
|
|
);
|
|
}
|
|
});
|
|
});
|