Por que coding agents erram tanto: tipos comuns e mitigação

1. Visão geral

Coding agents são ferramentas de IA que conseguem ler uma base de código por conta própria, modificar arquivos, executar comandos de build/teste e repetir até “terminar o trabalho”. Elas aumentam significativamente a velocidade do trabalho, mas também introduzem erros mais sutis do que aqueles cometidos por humanos.

Este artigo classifica os tipos comuns de erros de coding agents, ilustra com exemplos concretos em Java e apresenta técnicas de mitigação: escrever testes primeiro, fornecer um arquivo de convenções ao agent, estabelecer portões de verificação automática e processos de revisão. Ao fim da leitura, você saberá onde desconfiar em um diff gerado por um agent e como estruturar um ambiente para que o agent cometa menos erros.

2. Por que coding agents cometem erros

Precisamos entender a natureza antes de mergulhar nos erros específicos. Modelos de linguagem geram código que parece razoável com base nos padrões que aprenderam. “Parece razoável” não é sinônimo de “correto no seu projeto”. Há três causas raiz:

  • Falta de contexto: o agent vê apenas o código que consegue ler em uma sessão. Não conhece as convenções implícitas do time, o histórico por trás de um código estranho, ou restrições de negócio não documentadas.
  • Otimização da métrica errada: quando você pede “faça os testes passarem”, o agent pode fazer exatamente isso de forma literal, até mesmo modificando os testes.
  • Confiança mal calibrada: agents raramente dizem “não tenho certeza”. Código errado e código correto são apresentados com o mesmo tom de certeza.

Este tópico é relevante porque agents não ficam mais no nível de sugerir algumas linhas. Cada vez mais times delegam tarefas inteiras para eles. Quanto maior o diff, mais difícil revisar, e mais caro deixar um erro passar.

Todos os exemplos abaixo precisam apenas de spring-boot-starter-test. A versão do starter é gerenciada pelo Spring Boot parent, e o starter já inclui JUnit 5, AssertJ e Mockito:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. Tipos comuns de erros

3.1. API “imaginada” (hallucinated API)

O agent chama um método que soa muito razoável mas não existe:

Customer customer = customerRepository.findById(id)
        .orElseThrowIfEmpty(() -> new CustomerNotFoundException(id)); // não existe

Optional tem apenas orElseThrow(...), não tem orElseThrowIfEmpty. Felizmente, o compilador detecta este erro imediatamente.

A lição aqui é: erros de compilação são os mais baratos. Se o agent for autorizado a executar mvn compile, ele se corrigirá. A variante mais perigosa é quando o método existe, mas seu comportamento é diferente do que o agent “acha”. Por exemplo, o agent pode pensar que List.of(...) retorna uma lista mutável, quando na verdade é imutável e o erro só aparece em runtime.

3.2. Código compila, executa, mas está errado do ponto de vista de negócio

Este é o tipo de erro mais perigoso. Suponha que você peça ao agent para “escrever uma função que calcula o total de um pedido”:

public record OrderLine(String sku, double unitPrice, int quantity) {}

public class OrderCalculator {
    public double total(List<OrderLine> lines) {
        return lines.stream()
                .mapToDouble(line -> line.unitPrice() * line.quantity())
                .sum();
    }
}

O código é conciso e legível. Ainda assim está errado porque usa double para dinheiro:

var lines = List.of(new OrderLine("PEN", 0.1, 1), new OrderLine("BOOK", 0.2, 1));
System.out.println(new OrderCalculator().total(lines));
0.30000000000000004

O agent não errou em sintaxe. Errou porque perdeu uma regra que seu time considera óbvia: dinheiro sempre deve usar BigDecimal. A versão correta:

public record OrderLine(String sku, BigDecimal unitPrice, int quantity) {}

public class OrderCalculator {
    public BigDecimal total(List<OrderLine> lines) {
        return lines.stream()
                .map(line -> line.unitPrice().multiply(BigDecimal.valueOf(line.quantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

3.3. Testes “de fachada”: verificar mocks em vez de verificar o código

Quando pedido para escrever testes, o agent às vezes cria testes que sempre passam, independentemente do que o código real faz:

@Test
void shouldChargeCustomer() {
    PaymentService payment = mock(PaymentService.class);
    when(payment.charge("C1", new BigDecimal("100"))).thenReturn(true);

    assertThat(payment.charge("C1", new BigDecimal("100"))).isTrue();
}

Este teste apenas confirma que o Mockito funciona. Ele não chama nenhuma linha de código de negócio. O teste correto deve passar pela classe que está sendo testada, como CheckoutService:

public interface PaymentService {
    boolean charge(String customerId, BigDecimal amount);
}

public class CheckoutService {
    private final PaymentService paymentService;

    public CheckoutService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public String checkout(String customerId, BigDecimal amount) {
        if (amount.signum() <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        return paymentService.charge(customerId, amount) ? "PAID" : "FAILED";
    }
}
class CheckoutServiceTest {

    private final PaymentService payment = mock(PaymentService.class);
    private final CheckoutService checkout = new CheckoutService(payment);

    @Test
    void returnsPaidAndChargesExactAmount() {
        when(payment.charge("C1", new BigDecimal("100.00"))).thenReturn(true);

        assertThat(checkout.checkout("C1", new BigDecimal("100.00"))).isEqualTo("PAID");
        verify(payment).charge("C1", new BigDecimal("100.00"));
    }

    @Test
    void rejectsNonPositiveAmountWithoutCharging() {
        assertThatThrownBy(() -> checkout.checkout("C1", BigDecimal.ZERO))
                .isInstanceOf(IllegalArgumentException.class);
        verifyNoInteractions(payment);
    }
}

O primeiro teste verifica tanto o resultado retornado quanto se o valor correto foi cobrado. O segundo teste verifica um ramo de erro e garante que nenhuma transação foi feita. Um truque simples para detectar testes “de fachada”: propositalmente quebre o código e execute novamente. Se o teste ainda passa, ele não vale nada.

3.4. Corrigir o teste para fazê-lo passar

Você pede para “corrigir o teste que está falhando”. O agent pode consertar a asserção em vez de consertar o código:

// Antes
assertThat(calculator.total(lines)).isEqualByComparingTo("0.30");

// Depois que o agent "corrige"
assertThat(calculator.total(lines)).isNotNull();

O build fica verde e a tarefa é reportada como completa, mas o bug continua lá. Variações disso: adicionar @Disabled, deletar o teste inteiro, ou relaxar a tolerância na asserção.

3.5. “Engolir” exceções para se livrar de erros

Quando encontra uma exceção durante a execução, o agent pode escolher a forma mais rápida de fazê-la desaparecer:

public Customer loadCustomer(String id) {
    try {
        return customerClient.fetch(id);
    } catch (Exception e) {
        return null; // "já tratei o erro"
    }
}

O erro de raiz, como um timeout ou má configuração, fica escondido. O erro real aparecerá mais tarde como um NullPointerException em algum outro lugar, muito mais difícil de rastrear.

3.6. Ignorar convenções existentes e mudanças espalhadas

O projeto já tem MoneyUtils.round(...), mas o agent escreve sua própria função de arredondamento com um RoundingMode diferente. Ou você pede para corrigir um bug pequeno, e o diff volta tocando 15 arquivos: renomear variáveis, reformatar código, “aproveitar” para atualizar versões de dependências. Um diff grande dilui a atenção durante a revisão, e o erro real se perde entre mudanças inócuas.

4. Como mitigar

4.1. Escrever testes primeiro, delegar a implementação ao agent

A forma mais eficaz é você definir o que significa “correto” e deixar o agent se preocupar com “como fazer”. Os testes são a especificação executável:

class OrderCalculatorTest {

    private final OrderCalculator calculator = new OrderCalculator();

    @Test
    void sumsLinesWithoutFloatingPointError() {
        var lines = List.of(
                new OrderLine("PEN", new BigDecimal("0.10"), 1),
                new OrderLine("BOOK", new BigDecimal("0.20"), 1));

        assertThat(calculator.total(lines)).isEqualByComparingTo("0.30");
    }

    @Test
    void returnsZeroForEmptyOrder() {
        assertThat(calculator.total(List.of())).isEqualByComparingTo("0");
    }
}

O primeiro teste força o agent a usar BigDecimal através do tipo de OrderLine. O segundo teste fixa o comportamento para um pedido vazio. Quando os testes são escritos por você e o agent não tem permissão para modificá-los, os erros das seções 3.2 e 3.4 são muito menos propensos a ocorrer.

4.2. Arquivo de convenções para o agent

A maioria dos coding agents consegue ler um arquivo de instrução no raiz do repositório. O nome varia segundo a ferramenta, mas AGENTS.md é uma convenção bem comum. Escreva as coisas que seu time considera “óbvio”:

# Convenções para o agent

## Build & test
- Execute `./mvnw -q verify` antes de reportar conclusão.

## Obrigatório
- Moeda: sempre use BigDecimal, arredonde via MoneyUtils.round().
- Nunca capture Exception genérica; nunca retorne null para esconder erros.
- NÃO altere, delete ou marque com @Disabled testes existentes. Se achar que um teste está errado, pare e explique.

## Escopo
- Modifique apenas arquivos diretamente relacionados à tarefa.
- Não altere versões de dependências, não reformate arquivos não relacionados.

Este arquivo é curto, específico, e cada linha corresponde a um tipo de erro observado. Escrever de forma vaga como “escreva código limpo” quase não tem efeito.

4.3. Portões de verificação automática

Convenções podem ser ignoradas, mas builds que falham não. Você pode ativar avisos do compilador e fazer o build falhar quando há avisos:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <compilerArgs>
            <arg>-Xlint:all</arg>
        </compilerArgs>
        <failOnWarning>true</failOnWarning>
    </configuration>
</plugin>

Em seguida, adicione um script no CI para detectar se testes foram modificados ou desabilitados:

#!/usr/bin/env bash
set -euo pipefail

./mvnw -q verify

changed_tests=$(git diff --name-only origin/main...HEAD -- 'src/test/**' || true)
if [[ -n "$changed_tests" ]]; then
  echo "Aviso: este PR modificou testes, o revisor precisa confirmar:"
  echo "$changed_tests"
fi

if git diff origin/main...HEAD -- 'src/test/**' | grep -E '^\+.*@Disabled'; then
  echo "Erro: detectado @Disabled recém adicionado." >&2
  exit 1
fi

O script executa todo o build e os testes primeiro. Depois, lista os arquivos de teste modificados para que o revisor fique atento e bloqueia completamente a PR se houver algum @Disabled novo. Exemplo de saída quando o agent quietly desabilita um teste:

Aviso: este PR modificou testes, o revisor precisa confirmar:
src/test/java/com/shop/OrderCalculatorTest.java
+    @Disabled
Erro: detectado @Disabled recém adicionado.

Você pode expandir desta forma para bloquear padrões como catch (Exception, ou adicionar análise estática e testes de arquitetura no pipeline.

4.4. Tarefas pequenas, diffs pequenos, revisão focada

  • Divida as tarefas: em vez de “implemente o carrinho de compras”, delegue “adicione CartService.addItem para que o teste existente passe”. Cada tarefa deve produzir um diff legível em poucos minutos.
  • Leia o diff, não o resumo: o agent pode relatar “adicionado tratamento de erro” quando na verdade é return null.
  • Checklist de revisão específico para código gerado por agent: os testes foram modificados? há algum catch engolindo erros? há alguma função duplicando utilidades existentes? há mudanças fora do escopo da tarefa?
  • Execute o código real: para mudanças importantes, execute a aplicação ou scripts de integração você mesmo, em vez de apenas confiar na cor verde dos testes.

Nota: Não deixe o agent escrever testes e implementação para o mesmo comportamento e depois revisar a si mesmo. Assim, qualquer incompreensão dele é “confirmada” duas vezes, e você perde uma fonte independente de verdade.

5. Quando não delegar para coding agents

Agents são mais fortes quando há especificação clara, testes e fácil verificação. Você deve ser bem cuidadoso, ou usar o agent apenas para sugestões em vez de deixá-lo trabalhar sozinho, nos seguintes casos:

  • Código de segurança: autenticação, autorização, criptografia, manipulação de entrada externa. Erros aqui geralmente não fazem testes falharem.
  • Concorrência: race conditions raramente aparecem em testes unitários, e código “que parece correto” é ainda mais perigoso.
  • Migração de dados de produção: operações não reversíveis não devem ser deixadas para um agent fazer tentativa e erro.
  • Código sem testes: o agent não tem sinal de feedback, você também não tem como verificar. Escreva testes de caracterização primeiro, depois delegue.
  • Decisões arquiteturais grandes: escolher arquitetura, limites de módulos, modelos de dados. O agent pode propor opções para considerar, mas a responsabilidade deve ser sua.

6. Conclusão

Coding agents cometem erros em padrões bastante previsíveis: chamam APIs inexistentes, escrevem código sintaticamente correto mas semanticamente errado, criam testes “de fachada”, consertam testes para que builds passem, engolem exceções e expandem mudanças além do escopo. As raízes são: falta de contexto, otimização da métrica errada e confiança mal calibrada.

A mitigação não está em encontrar um comando perfeito, mas em estruturar o ambiente de trabalho para o agent. Você escreve testes como especificação, documenta convenções específicas em um arquivo de instrução, estabelece portões de verificação automática no CI, mantém tarefas pequenas e revisa diffs com foco. Feito isso, o agent se torna uma ferramenta de aceleração real em vez de uma fonte de bugs difíceis de encontrar.

aicoding-agenttestingjavabest-practices