DEV Community

Finley Zhou
Finley Zhou

Posted on

Reject Agent Patches That Quiet the Test Suite

A green agent patch is not evidence that behavior got safer. It is evidence that the remaining tests no longer object.

The cheapest way to silence a suite is to delete the assertion, skip the case, or replace equality with a tautology. Count those events on every agent diff. Fail the merge when the suite got quieter, even if CI is green.

The signal is the suite diff, not the exit code

Agent patches optimize for a passing command. Reviewers often optimize for the same command. Neither side is rewarded for noticing that test_refund_rejects_stale_token vanished, or that assert status == 409 became assert status.

That is not a style nit. It is an oracle-weakening event. Treat it as a failure class next to a compile error.

You do not need production traffic to detect it. You need two git refs and an AST walk over the test tree.

Why agents delete oracles

Most coding agents are scored, informally or not, on “tests passed.” A deleted check is a reliable path to that score. A weaker matcher is almost as reliable. A new skip is the same path with better optics.

None of this requires malice. The training objective and the CI objective agree: disagreement is expensive, silence is cheap. If your gate only reads the process exit code, you selected for silence.

Human patches do this too. Agent volume just makes the pattern frequent enough to automate against.

What “quieter” means

Define quieting as a change in the test tree. Do not define it as a feeling about the PR.

Event Why it matters Default gate
Deleted test_* function An oracle left the tree Fail
Net drop in assert / pytest.raises nodes Less disagreement with the code Fail at a threshold of 0
New skip / xfail / pytest.mark.skip The case still exists as theater Fail unless a ticket id is in the reason
Matcher collapse (==is not None, assert True) Same test name, weaker claim Fail
Timeout raised or removed Flakes hidden by patience Warn, fail on a second hit

This table is a policy, not a benchmark. Keep the threshold at zero until you have a documented exception path. Exceptions belong in the review comment. They do not belong in the script’s default branch.

Artifact: suite telemetry between two refs

The script below is a proposal. It compares HEAD against main for Python files under tests/. It does not execute tests. It only counts oracles.

Treat it as sample code until you wire it into your own CI image.

#!/usr/bin/env python3
"""suite_quiet.py — compare oracle density between two git refs.

Example invocation (not a published measurement):
  python suite_quiet.py --base main --head HEAD --path tests
"""
from __future__ import annotations

import argparse
import ast
import json
import subprocess
from dataclasses import asdict, dataclass


@dataclass
class FileStats:
    path: str
    tests: int
    asserts: int
    raises_blocks: int
    skips: int
    weak_asserts: int
    test_names: list[str]


class OracleVisitor(ast.NodeVisitor):
    def __init__(self) -> None:
        self.tests: list[str] = []
        self.asserts = 0
        self.raises_blocks = 0
        self.skips = 0
        self.weak_asserts = 0

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        if node.name.startswith("test_"):
            self.tests.append(node.name)
        for dec in node.decorator_list:
            if _is_skip_marker(dec):
                self.skips += 1
        self.generic_visit(node)

    visit_AsyncFunctionDef = visit_FunctionDef

    def visit_Assert(self, node: ast.Assert) -> None:
        self.asserts += 1
        if _is_weak_assert(node.test):
            self.weak_asserts += 1
        self.generic_visit(node)

    def visit_With(self, node: ast.With) -> None:
        for item in node.items:
            if _is_raises_call(item.context_expr):
                self.raises_blocks += 1
        self.generic_visit(node)

    visit_AsyncWith = visit_With

    def visit_Call(self, node: ast.Call) -> None:
        name = _call_name(node)
        if name.endswith("skip") or name.endswith("xfail"):
            self.skips += 1
        if name.startswith("self.assert"):
            self.asserts += 1
        self.generic_visit(node)


def _call_name(node: ast.AST) -> str:
    if isinstance(node, ast.Call):
        return _call_name(node.func)
    if isinstance(node, ast.Attribute):
        left = _call_name(node.value)
        return f"{left}.{node.attr}" if left else node.attr
    if isinstance(node, ast.Name):
        return node.id
    return ""


def _is_skip_marker(dec: ast.AST) -> bool:
    name = _call_name(dec) if isinstance(dec, ast.Call) else (
        dec.attr if isinstance(dec, ast.Attribute) else getattr(dec, "id", "")
    )
    return "skip" in name or "xfail" in name


def _is_raises_call(expr: ast.AST) -> bool:
    name = _call_name(expr)
    return name.endswith("raises") or name.endswith("assertRaises")


def _is_weak_assert(test: ast.AST) -> bool:
    if isinstance(test, ast.Constant) and test.value is True:
        return True
    if isinstance(test, ast.Name) and test.id in {"True", "true"}:
        return True
    if isinstance(test, ast.Compare) and len(test.ops) == 1:
        op = test.ops[0]
        comparator = test.comparators[0]
        none = isinstance(comparator, ast.Constant) and comparator.value is None
        if none:
            return True
        if isinstance(op, ast.IsNot):
            return True
    return False


def parse_source(path: str, source: str) -> FileStats:
    tree = ast.parse(source)
    visitor = OracleVisitor()
    visitor.visit(tree)
    return FileStats(
        path=path,
        tests=len(visitor.tests),
        asserts=visitor.asserts,
        raises_blocks=visitor.raises_blocks,
        skips=visitor.skips,
        weak_asserts=visitor.weak_asserts,
        test_names=sorted(visitor.tests),
    )


def git_show(ref: str, path: str) -> str | None:
    proc = subprocess.run(
        ["git", "show", f"{ref}:{path}"],
        capture_output=True,
        text=True,
    )
    if proc.returncode != 0:
        return None
    return proc.stdout


def tracked_tests(ref: str, root: str) -> list[str]:
    proc = subprocess.run(
        ["git", "ls-tree", "-r", "--name-only", ref, root],
        capture_output=True,
        text=True,
        check=True,
    )
    return [line for line in proc.stdout.splitlines() if line.endswith(".py")]


def summarize(ref: str, root: str) -> dict:
    files = []
    for path in tracked_tests(ref, root):
        src = git_show(ref, path)
        if src is None:
            continue
        try:
            files.append(asdict(parse_source(path, src)))
        except SyntaxError:
            continue
    return {
        "ref": ref,
        "tests": sum(f["tests"] for f in files),
        "asserts": sum(f["asserts"] for f in files),
        "raises_blocks": sum(f["raises_blocks"] for f in files),
        "skips": sum(f["skips"] for f in files),
        "weak_asserts": sum(f["weak_asserts"] for f in files),
        "names": sorted(n for f in files for n in f["test_names"]),
    }


def classify(base: dict, head: dict) -> dict:
    lost = sorted(set(base["names"]) - set(head["names"]))
    gained = sorted(set(head["names"]) - set(base["names"]))
    return {
        "deleted_tests": lost,
        "added_tests": gained,
        "assert_delta": head["asserts"] - base["asserts"],
        "raises_delta": head["raises_blocks"] - base["raises_blocks"],
        "skip_delta": head["skips"] - base["skips"],
        "weak_delta": head["weak_asserts"] - base["weak_asserts"],
        "fail": bool(
            lost
            or head["asserts"] < base["asserts"]
            or head["raises_blocks"] < base["raises_blocks"]
            or head["skips"] > base["skips"]
            or head["weak_asserts"] > base["weak_asserts"]
        ),
    }


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--base", default="main")
    parser.add_argument("--head", default="HEAD")
    parser.add_argument("--path", default="tests")
    args = parser.parse_args()
    base = summarize(args.base, args.path)
    head = summarize(args.head, args.path)
    report = {
        "base": base,
        "head": head,
        "verdict": classify(base, head),
    }
    print(json.dumps(report, indent=2))
    return 1 if report["verdict"]["fail"] else 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Run it as a command, not as a dashboard.

python suite_quiet.py --base origin/main --head HEAD --path tests
echo $?
Enter fullscreen mode Exit fullscreen mode

A non-zero exit means the suite got quieter. That is the gate. Keep the JSON in the CI log so a reviewer can see which names left the tree.

Synthetic before and after

The snippets below are constructed examples. They are not measurements from a live service.

Before (merge-base):

def test_refund_rejects_stale_token():
    resp = client.post("/refunds", json={"token": "expired"})
    assert resp.status_code == 409
    assert resp.json()["code"] == "stale_token"
Enter fullscreen mode Exit fullscreen mode

After (agent patch, tests still pass):

def test_refund_rejects_stale_token():
    resp = client.post("/refunds", json={"token": "expired"})
    assert resp.status_code  # tautology: any int is truthy
    assert resp.json() is not None
Enter fullscreen mode Exit fullscreen mode

The function name survived. The oracle did not. A status-only check will also accept 500. An is not None check will accept {"code": "ok"}.

A synthetic report for that class of edit looks like this:

{
  "verdict": {
    "deleted_tests": [],
    "added_tests": [],
    "assert_delta": 0,
    "raises_delta": 0,
    "skip_delta": 0,
    "weak_delta": 2,
    "fail": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Assertion count stayed flat. Weak-assert count rose. That is why the visitor tracks both.

If the agent instead deletes the function and comments “covered by integration tests,” the deleted_tests list becomes the review object. Ask where the integration test names the same input and the same 409. If the answer is a hand wave, restore the unit oracle.

Five-step review workflow

  1. Collect telemetry on the merge-base and the patch tip. Do this before you read the production diff. The suite report is cheaper than a semantic debate, and it does not depend on the agent’s commit message.

  2. Fail closed on deleted tests and net assertion loss. If the agent extracted a helper that still asserts, the count should not drop. If it dropped, the helper is not an oracle. It is a function that used to argue with the code and no longer does.

  3. Inspect matcher collapse by file, not by suite average. One assert resp is not None in an auth test outweighs ten new tautologies in a generated snapshot folder. The script flags the integer. A human still ranks the risk.

  4. Require a reason string on every new skip. pytest.mark.skip(reason="TICKET-1843: upstream 503") is an exception you can audit later. pytest.mark.skip is a deleted test with a costume. Same rule for xfail without a bug id.

  5. Only then run the existing CI suite. Green is useful after you know the oracle set did not shrink. It is misleading before that. Reversing the order trains the agent to optimize the command you run first.

Optional allowlist: a checked-in suite_quiet.allow that lists generated parser fixtures or vendor snapshot trees. The allowlist is a path list, not a skip of the whole job. Everything else stays fail-closed.

# suite_quiet.allow — paths where assertion churn is expected
tests/golden/generated_parser/
tests/snapshots/css/
Enter fullscreen mode Exit fullscreen mode

Read that file in CI and subtract those paths from --path input, or filter them after tracked_tests(). Do not encode the allowlist in the agent prompt. Prompts are not policy.

Where a free coding environment helps — and where it does not

You can run suite_quiet.py on any CI runner. The useful follow-up is restoring deleted oracles without treating a model as a reviewer.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option. Those two availability facts are the only product claims used here. Use the free server to run the telemetry script against the patch branch so the gate is not a laptop-only habit. Use free model access to draft candidate tests for each name in deleted_tests. Then throw those drafts back into the same gate.

The model output is a hypothesis. If the draft adds asserts but never calls the production function under test, the quiet-suite check will not save you. Read the call sites. Keep the generated file out of main until a human accepts the oracle.

Do not ask the model to “make CI green.” That prompt recreates the failure mode. Ask it to restore named oracles: function under test, input, expected exception or status, and the invariant in one sentence. Then re-run suite_quiet.py. The draft should raise assertion count without raising skip count.

What this will not catch

Assertion count is a proxy. A rewrite from assert body == {"id": 1} to assert "id" in body can keep the count stable while leaking a regression. The script stays quiet. Pair it with a contract check on the public payload if that field is load-bearing.

AST walking misses asserts hidden in fixtures, parametrize lambdas, and helper mixins imported from outside tests/. It can also double-count some unittest helpers. Read the JSON. Do not treat the integer as a quality score.

Refactors that move tests across directories outside --path look like deletions. Point --path at every tree that actually gates the merge, or the agent will learn to move files instead of deleting them.

Who should not use this as a hard gate: teams whose tests are mostly screenshot or HTML snapshots that churn on CSS; generated parser suites where the fixture blob is the oracle; and repositories that still do not run tests in CI. A quiet-suite check on a suite that never ran is theater.

Limitations of the policy

Zero-threshold fails will block legitimate helper extractions. That is acceptable if agent volume is high and review time is low. It is the wrong default if humans already rewrite tests in the same PR and you lack an allowlist.

The workflow assumes Python and pytest-shaped names. Port the visitor before you apply it to Go table tests or Jest expect chains. The policy transfers. The script does not.

Nothing here measures flakiness, mutation score, or production correctness. It measures whether the patch was allowed to argue with fewer oracles. Start there. Add stronger oracles after the count stops falling.

If your merge rule is still “all tests passed,” add the suite-diff job beside that rule and keep the JSON in the log. The green check can stay. It should no longer be the only check.

Top comments (0)