Dois assistentes, um só conjunto de ferramentas

Product

O Notez tem dois jeitos de apontar uma IA para as suas notas. Um é o chat dentro do app; o outro é o servidor MCP, pelo qual o seu próprio assistente — Claude Code, Cursor, o que você usar — lê e escreve no mesmo espaço de trabalho.

A ideia era ter duas portas para a mesma sala. Na semana passada, alguém fez uma pergunta razoável: o assistente consegue excluir uma nota? A resposta era não dos dois lados, o que estava certo. A resposta à pergunta seguinte foi menos confortável.

Dezesseis contra dez

O chat tinha dezesseis ferramentas. O MCP tinha dez.

A diferença não veio de uma decisão. Em agosto, ensinei o chat do app a ler e escrever propriedades personalizadas, tags e colunas do quadro — seis ferramentas novas. Esse trabalho chegou ao chat e parou ali. Ninguém decidiu que o MCP ficaria sem elas. Ninguém percebeu que ele ficou sem elas.

Pior: a parte em comum era inconsistente a ponto de os dois parecerem produtos diferentes. O chat tinha search_notes; o MCP tinha list_notes, que fazia o mesmo com mais filtros. O chat tinha semantic_search; o MCP chamava isso de search_similar_notes. Mesma capacidade, outro nome, outro formato — o tipo de divergência que ninguém planeja e em que todo mundo acaba tropeçando.

É assim que software apodrece no dia a dia. Não por uma decisão ruim, mas por uma decisão que não foi repetida: uma mudança certa para a interface em que mexeu e que deixou a outra de fora sem avisar.

O que eu não unifiquei

A correção óbvia é uma implementação única, chamada pelas duas interfaces. Tentei isso e voltei atrás, porque duas diferenças se mostraram estruturais.

Os resultados precisam ser diferentes. O MCP responde em JSON e lança erros tipados, porque fala com um programa. O chat responde em texto corrido com links como [Budget 2024](note:a1b2…), porque o app extrai esses links para desenhar os pequenos chips clicáveis de nota embaixo da resposta. Faça o chat devolver JSON, e os chips somem.

Os erros também precisam ser diferentes. Quando o chat encontra um problema, ele devolve uma frase — "oldText was not found in the note; re-read it rather than guessing" — porque o modelo lê essa frase e se corrige na rodada seguinte, dentro de um limite de cinco rodadas. O MCP lança uma exceção e deixa o cliente decidir. Imponha um contrato único aos dois, e um deles piora.

Então unifiquei a parte que deve ser idêntica — quais ferramentas existem, como se chamam, que argumentos recebem — e deixei a apresentação a cargo de cada interface. A garantia é um teste que monta a lista real de ferramentas do MCP, compara com as declarações do chat e quebra o build em qualquer diferença. Havia exatamente uma exceção permitida, escrita no teste junto com o motivo: mover um cartão entre colunas do quadro ficou só no app, porque um quadro parecia uma organização visual que um assistente do outro lado de uma API não conseguiria ver. Essa exceção não existe mais: hoje o MCP também move cartões entre colunas, e as únicas ferramentas do MCP que o chat não tem são as duas que enviam arquivos e, nas conexões por login, a que lista seus espaços de trabalho.

Um comentário dizendo "mantenha isto sincronizado" não custaria nada. Também seria ignorado, como o anterior foi.

Depois tentei adicionar "mover"

Excluir, fixar e criar já aninhado foram simples. Mover uma nota para dentro de outra parecia igualmente simples — o app tem o item de menu Mover para dentro de... há meses, então a parte difícil com certeza estava feita.

Não estava.

O Notez guarda a árvore de notas como uma closure table: não só "o pai de B é A", mas uma linha para cada par ancestral-descendente, com a distância entre eles. Com isso, "me mostre todas as notas abaixo desta, em qualquer profundidade" vira uma única consulta indexada, em vez de um percurso recursivo.

O código de mover reescrevia essas linhas para a nota movida. Só para a nota movida.

Então, com A > B > C, mover B para dentro de D dava isto:

  • Ancestrais de B: D. Correto.

  • Ancestrais de C: A e B. Errado. A não tem mais nada a ver com C, e D nem aparece.

A trilha de navegação de C mostrava Árvore A › Árvore B › Árvore C, enquanto B na verdade estava dentro de D. O bug esteve acessível pelo próprio menu do app o tempo todo. Só faltava alguém mover uma nota que já tinha notas filhas — e depois abrir uma dessas filhas.

Eu teria lançado uma ferramenta com a qual um assistente faria isso cem vezes por minuto.

O teste que eu quase não escrevi

A correção lê a subárvore, apaga só as linhas que saem dela e liga de novo cada descendente aos novos ancestrais. Os testes unitários passaram. Eles substituem o banco de dados por mocks, então o que confirmam de fato é que o código faz o que o autor esperava.

O teste de ponta a ponta — navegador real, banco de dados real, mover uma nota que tem uma filha, descer até a neta e ler a trilha de navegação dela — falhou na primeira execução. Contra a correção. Era para isso que eu o tinha escrito.

A causa era um detalhe que ninguém adivinharia e que nenhum mock revelaria: uma nota criada no nível superior não tem a linha que aponta para si mesma na closure table. Todas as outras notas têm uma linha dizendo "sou meu próprio ancestral, a distância zero"; uma nota simples de nível superior não tem, porque só o caminho de código que anexa um pai escreve essa linha. Então "selecione tudo abaixo desta nota" devolvia as filhas, mas não a própria nota — e a etapa de exclusão, encarregada de limpar os vínculos que saem da subárvore, cortou sem cerimônia o vínculo da nota com as próprias filhas.

Resultado: o novo pai ficou sem nenhuma filha, e o único ancestral da neta era uma nota dois níveis acima dela.

Uma verificação manual anterior tinha passado. Passou por sorte — a nota que usei no teste já tinha mudado de pai com o código antigo, que sempre escrevia essa linha, então ela tinha uma.

Esse é todo o argumento a favor de testes que tocam a coisa real. Um mock registra aquilo em que você acredita. Um banco de dados registra o que é verdade. Quando os dois discordam, é melhor descobrir por um teste do que por alguém cujas notas se reorganizaram sozinhas.

O que você ganha

As duas interfaces agora têm as mesmas dezoito ferramentas. Seu assistente coloca uma nota dentro de outra, fixa a nota, manda para a lixeira — e avisa que levou junto quatro subnotas, para você saber o tamanho do que acabou de acontecer antes de decidir se desfaz.

Duas correções menores vieram de carona. Listar notas pelo MCP devolvia notas da lixeira misturadas com as ativas, a não ser que você as excluísse explicitamente, ao contrário do que dizia a própria documentação. E perguntar ao chat "no que eu tenho trabalhado?" ordenava por data de criação, o que respondia a uma pergunta sobre esta semana com notas de março.

Nada disso é um recurso que alguém vai querer mostrar num print. É a versão do produto que faz o que as próprias páginas de ajuda já diziam que ele fazia — o que, a certa altura da vida de um projeto, é o tipo de versão mais útil.

Continue lendo