ReadingYour AI Says the Bug Is Fixed, but the Test Never Failed: Two Checks Before It Ships
6 min read

Your AI Says the Bug Is Fixed, but the Test Never Failed: Two Checks Before It Ships

An agent fixing a bug writes a test that already passes, then makes the callers happy by reshaping a public function. Two read-only git gates reject both moves before the commit exists.

Listen to the audio overview(12:41)Fenrir Studio Voice
0:00
12:41

An agent fixing a bug has two cheap ways to look done: write the regression test after the fix so it is green on its first run, and reshape a public function to satisfy the one caller in the diff. Part 5 of this series, the behavior gate, is a pre-commit hook that records a content hash of every existing test body, re-runs those tests in a fresh subprocess at the commit boundary, and rejects the commit when a hashed body changed or a hashed test fails. It sees neither move. This part adds two read-only rules in front of the same hook.

Three rules against the same six moves. A prompt rule sees nothing at the commit boundary. The behavior gate runs the pinned baseline, so a new green test and a reshaped public function both pass it. R1 reads the reproducer's exit code and command line; R2 compares public names and arity against HEAD. A private helper stays free by design. A. Prompt rule B. Behavior gate C. Red test + signature fence "write the failing test first" read at turn 1, not at commit no exit code what it sees nothing at the boundary the diff already landed 6 of 6 moves pass green test, pre-fix pass reproducer hangs pass || true in command pass public kwarg added pass public helper inlined pass private _helper reshaped pass exit 0 commit lands behavior_gate.py runs the pinned tests exit 0 or 1 what it sees sha256 of each pinned body a pinned assertion that fails nothing about new tests green test, pre-fix pass reproducer hangs n/a || true in command n/a public kwarg added pass public helper inlined pass private _helper reshaped pass exit 0 pinned tests still green repro_fence.py R1 runs the test, R2 parses HEAD exit 0 or 1 what it sees exit code of the reproducer shell tokens in the command public names and arity vs HEAD green test, pre-fix caught reproducer hangs caught || true in command caught public kwarg added caught public helper inlined caught private _helper reshaped allowed exit 1 one line per move, then the diff
Figure 1. Three rules against the same six moves. A prompt rule sees nothing at the commit boundary. The behavior gate runs the pinned baseline, so a new green test and a reshaped public function both pass it. R1 reads the reproducer's exit code and command line; R2 compares public names and arity against HEAD. A private helper stays free by design.
PART 01

Two moves the behavior gate does not see

01

The Friday commit that did not land

Friday afternoon I asked the agent to stamp a theme tag into every PNG the social-card renderer writes. Its first patch added a theme keyword to render_html_to_png and to build_carousel, two functions with five callers outside the diff. Every test in the diff passed. The pre-commit hook printed one line per function and exited 1. In a two-file scratch repo the line reads:

git commit, rejected
TXT
[R2-fence] render.py: 'render_html_to_png' signature changed (html,output_path)d0 -> (html,output_path,theme)d1
exit=1

The name, the shape at HEAD, the shape now. The agent read it, kept the signature, and moved the theme into module state (the patch that passes the fence, below).

02

What the behavior gate does not see

The test that was green from the start. The agent fixes the bug, then writes the regression test, green on its first run. Nothing shows it failed before the fix, and a new test is not in the pinned baseline the behavior gate hashes.

The fix by reshaping. To satisfy one call site the agent adds a keyword, renames a method, or inlines a public helper. The tests in the diff pass; the callers outside it break at call time.

PART 02

The rules

03

R1: the test must be red first

repro_fence.py red --cmd "python3 -m pytest tests/test_x.py::test_bug -q" runs the reproducer on the current tree with shell=False and a hard timeout, and passes only when the command fails. Exit codes 0, 2, 5, 124 and 127 are rejected: a pass, a usage error, a collection error, a timeout, and a missing binary:

repro_fence.py
PYTHON
REJECTED_EXIT_REASONS: dict[int, str] = {
    0: "exited 0 on the current tree; a passing reproducer proves nothing",
    2: "exited 2 (usage error); the reproducer command itself is wrong",
    5: "exited 5 (import or collection error); the test never ran",
    124: "timed out; a hang is not a reproduction",
    127: "binary not found or not executable",
}

def check_reproducer(repo: Path, argv: list[str], timeout: int) -> list[Violation]:
    """R1: the reproducer must fail in the failing range on the current tree."""
    if not argv:
        return [Violation("R1-repro", "no reproducer command supplied")]
    code = run_argv(argv, repo, timeout)
    reason = REJECTED_EXIT_REASONS.get(code)
    if reason is None:
        return []
    if code == 124:
        reason = f"timed out after {timeout}s; a hang is not a reproduction"
    return [Violation("R1-repro", f"reproducer {shlex.join(argv)} {reason}")]

I gamed the first version within a day. pytest test_x.py || true exits 0 and is rejected, but sh -c "pytest test_x.py; exit 1" exits 1 and sails through. So R1 has a second half, R1-shape: the reproducer may not carry ||, &&, ;, exit, true, or false, including inside a sh -c string. A command that picks its own exit code cannot prove the bug.

04

R2: the public surface keeps its shape

repro_fence.py fence --rev HEAD --file a.py --file b.py reads each file at rev with git show, parses both versions with ast, and compares public symbols on a normalized signature rather than source text, so a docstring edit passes and a changed parameter list does not:

repro_fence.py
PYTHON
def signature_of(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
    """Normalized parameter shape: `(name,/,name,*va,name,**kw)dN`."""
    spec = node.args
    names = [arg.arg for arg in spec.posonlyargs]
    if spec.posonlyargs:
        names.append("/")
    names.extend(arg.arg for arg in spec.args)
    if spec.vararg is not None:
        names.append("*" + spec.vararg.arg)
    elif spec.kwonlyargs:
        names.append("*")
    names.extend(arg.arg for arg in spec.kwonlyargs)
    if spec.kwarg is not None:
        names.append("**" + spec.kwarg.arg)
    defaults = len(spec.defaults) + sum(1 for d in spec.kw_defaults if d is not None)
    return f"({','.join(names)})d{defaults}"

The dN suffix counts positional and keyword-only defaults together, so appending extra=None changes both the name list and the count, d0 to d1. A name at HEAD that is absent now is a removed symbol; a name in both with a different shape is a changed signature. Leading underscores are skipped, new public symbols are free, and a file the parser does not know prints R2-skip.

repro_fence.py
PYTHON
def diff_symbols(rel: str, before: dict[str, str], after: dict[str, str]) -> list[Violation]:
    """Public symbols that vanished or changed shape between two sources."""
    out: list[Violation] = []
    for name, sig in sorted(before.items()):
        if name not in after:
            out.append(Violation("R2-fence", f"{rel}: public symbol '{name}' was removed"))
        elif after[name] != sig:
            out.append(
                Violation("R2-fence", f"{rel}: '{name}' signature changed {sig} -> {after[name]}")
            )
    return out

In the pre-commit hook the fence reads the staged copy of each file, so an unstaged edit cannot make a staged one look safe. Above 30 staged files it prints a notice and fails open. On the three-file scratch repo the run is 0.08 s.

PART 03

The fix

05

The patch that passes the fence

The renderer needs the theme and the signature cannot change. A module-level setter satisfies both: one public function added, zero changed.

render.py
PYTHON
_RENDER_THEME = ["paper"]


def set_render_theme(theme: str) -> None:
    """Records the theme that later render_html_to_png calls stamp into the PNG."""
    _RENDER_THEME[0] = theme


def render_html_to_png(html, output_path):
    """Writes html to output_path as a PNG."""
    ...
    stamp_png(output_path, _RENDER_THEME[0])   # new line, same signature
    return output_path

Each command handler calls set_render_theme(theme) before it renders. The public signature is byte-identical to HEAD, the behavior changed, and the five callers outside the diff were not touched. The commit lands.

Four gotchas from two weeks behind the rule. A keyword default is still a signature change; add a public name_with(..., *, extra=None) and have the old name delegate. A top-level def test_* is public, so renaming a test is rejected. Inlining a public helper is a removed symbol. And one honest false positive: a deliberate CLI change, audit_social_bundle()d0 -> (slug)d0. The shape that passed read sys.argv[1] inside the unchanged body. That is the cost of the rule.

ast-circuit-breakerScreen recording
Agent turn in Antigravity: R2 rejects the added keyword, the setter patch lands, then R1 rejects a green reproducer and a shell-shaped one
Show the shell command (reproduce locally)
git add -- render.py && git commit -m 'render: stamp the theme'
06

What each rule can see

Six moves, one scratch repo, three rules. Every cell is an exit code read off the terminal.

MoveBehavior gate (part 5)R1 red testR2 fence
Regression test written green after the fixMissed, not in the pinned baselineCaught, exit 1 on code 0n/a
Reproducer that hangsn/aCaught, timed out after 2 sn/a
|| true appended to the reproducern/aCaught, R1-shape, before anything runsn/a
Public keyword added to satisfy one callerMissed while pinned tests passn/aCaught, d0 -> d1
Public helper inlined awayMissed until a pinned test imports itn/aCaught, removed symbol
Private _helper reshapedMissedn/aAllowed by design, exit 0

The last row is deliberate. A fence on private names would turn every refactor into an override, and an override typed on every commit stops being a gate. The underscore is the contract.

PART 04

The boundary

07

What these gates cannot see

R2 sees Python only. A .ts or .sh file prints R2-skip and passes.

R2 reads names and arity, not types or semantics. A function that keeps its parameter list and changes its return contract passes. That is the behavior gate's job.

R1 proves the test fails now, not that it fails for the right reason. A test that fails on its own typo is red. Exit code 5 catches the import errors; the rest is on the author.

Three papers this year measured the problem from outside. TDAD (Mar 2026) cut regressions on SWE-bench Verified from 6.08% to 1.82% with a code-to-test map at commit time; test-first prompt instructions alone pushed them to 9.94%. How Coding Agents Fail Their Users (May 2026) read 20,574 real sessions; 91.49% of resolutions needed a user correction. DEPBENCH (Aug 2026) set 203 upgrade tasks with hidden signature changes; the best configuration solved 104. The fence is that last problem run backwards.

08

Same rule, different reader

Part 5, the behavior gate, put the pinned tests at the commit boundary. This part adds the contract on either side of the fix: red before it, same shape after it. The two rules are 361 lines of standard library Python with 26 tests.

The rule behind the Friday rejection had been a sentence in the system prompt for months. The agent read it at turn one. The hook read the staged files at commit time and printed the diff. Same rule, different reader, and only one returns an exit code.

Primary research and documentation

First published 19 Sep 2026 · last revised 22 Sep 2026 · 1 revision