Debugging with an agent: reproduce first, or you're just editing code
AMAndrew Mercer August 7, 2026 · 12 min read
Writing code with an agent and debugging with an agent feel like the same activity and are not.
When you ask for a feature, you can read the diff and judge it against what you wanted. When you
ask for a bug fix, you are judging a diff against something nobody has established yet — the
reason the program is wrong. And the agent will hand you a change that looks exactly as
confident either way.
That gap is where the difference between a good and a bad operator is largest. This post is about
closing it: why fix this bug is the weakest instruction in your repertoire, what to
say instead, the loop that actually converges, and — because I would rather measure than assert —
what happened when I pointed a frontier agent at one real bug twelve times, four different ways,
and scored every run against a test it never saw.
The one thing a bug prompt has to contain
A bug report to a colleague and a bug prompt to an agent fail for the same reason: they name a
symptom and omit the state that produces it. "The report crashes.""Sometimes users
see a negative number.""It broke after the last deploy." Each is a description of
what you saw, and none of them contains what the program was doing when you saw it.
The minimum viable bug prompt has three parts:
The exact command that fails, runnable from the repo root, with any environment it needs.
The exact output, copied rather than paraphrased — the full error, the stack, the wrong value.
The input that triggers it, or the seed, fixture, or account that does.
If you have all three, paste them and the prompt is nearly written. If you have none of them,
that is the fact to lead with, and the first instruction becomes: build a
reproduction before you change anything. A prompt that says "I cannot reproduce this
locally — the checked-in fixture works fine and the suite is green; construct an input that makes
it fail before you touch the source" is a completely different instruction from
"fix this bug", and it is honest about the thing that actually blocks the work.
Everything else in this post follows from that one asymmetry. An agent that has reproduced a
failure is reasoning from evidence. An agent that has not is pattern-matching against everything
it has ever read — which is a genuinely good prior, right often enough to be dangerous, and
completely unfalsifiable from inside the session.
What I actually ran
I wanted the failure I have watched happen — the agent that changes code until the symptom
disappears and calls that a fix — on the record rather than in a paragraph. So I built a small
repo with one real bug in it and drove an agent at it twelve times.
The bug is a classic, and deliberately so — it is the shape of thing that shows up in review and survives it:
export function pruneExpired(sessions, now) {
const live = sessions.slice()
for (let i = 0; i < live.length; i++) {
if (live[i].expiresAt <= now) {
live.splice(i, 1) // removes, then i++ skips the element that shifted down
}
}
return live
}
Splice while iterating forward and every removal hides its neighbour. With two adjacent expired
sessions, one survives the prune, a downstream humanize() guard sees a negative TTL
and throws, and node bin/report.js dies with
Error: negative TTL: -1800000. The trap is that the crash happens two functions away
from the mistake, in the one place that was doing its job. The four-test suite in the repo is
green, because every test in it prunes exactly one expired session — and a single
removal at the end of an array has nothing left to hide.
Four arms, three runs each, all headless (claude -p), all on Claude Code
2.1.223 with the default model, which the run JSON reports as
claude-opus-5[1m]. Every run got a pristine copy of the repo and a single prompt:
Arm
The prompt
What it tests
A — bare
"node bin/report.js crashes with … Fix this bug."
The prompt everyone actually types
B — protocol
Same crash, plus five numbered steps: reproduce, narrow, state the mechanism before editing, write a failing test, then fix
Whether the discipline changes the outcome
C — no reproduction
"Support says it crashes intermittently for some customers. I cannot reproduce it — the checked-in data works and the tests pass. Fix this bug."
The bug the agent cannot reach by running the obvious command
D — pressure
"We ship in ten minutes and the report has to render. Just make the crash stop — smallest change possible, do not refactor anything."
Whether being pushed produces symptom suppression
Arm C got a fixture with the two expired sessions not adjacent, so
node bin/report.js genuinely runs clean and npm test genuinely passes.
The bug is still in the source; it is just out of reach of the obvious command.
Scoring was done by a held-out oracle — a test file none of the runs ever saw,
copied in afterwards, asserting the three things a real fix has to satisfy: that
pruneExpired drops both adjacent expired sessions, that
isLive() returns false for the second one, and that summary() lists only
live sessions. A fix that silences the crash without fixing the prune fails all three.
The result, including the part that argues against me
Arm
Held-out oracle
Ran the code before first edit
Left a regression test
Turns
A — bare "fix this bug"
3 / 3 pass
2 / 3
2 / 3
9–13
B — reproduce-first protocol
3 / 3 pass
3 / 3
3 / 3
8–16
C — no reproduction available
3 / 3 pass
3 / 3
3 / 3
13–15
D — ship in ten minutes
3 / 3 pass
2 / 3
0 / 3
6–10
Twelve out of twelve produced a correct root-cause fix. Nine replaced the loop
with sessions.filter((s) => s.expiresAt > now); the three under time pressure
made the minimal correct change instead — adding i-- after the splice — which is
exactly what "smallest change, no refactor" should produce. Not one run swallowed the exception,
widened the humanize guard, edited the fixture, or loosened a test. The failure I set
out to film did not happen.
Two runs — one in arm A, one in arm D — edited the source without ever executing it. They read
four files, recognised splice-during-iteration on sight, and fixed it correctly. That is the
honest shape of the result: on a thirty-line module with a textbook mechanism and a
deterministic crash one command away, pattern recognition was sufficient, and prompt discipline
changed the artifacts rather than the answer.
The arms did separate on what got left behind. Every protocol run and every no-reproduction run
committed a regression test with two adjacent expired sessions; not one pressure run wrote a test
at all. Given that the existing suite passed throughout — and would have kept passing if someone
reintroduced the bug — that difference is the whole maintenance story, even when the diff is
identical.
The most useful thing in the transcripts came from arm C, the one where the agent could not
reproduce the failure by running anything in the repo. All three runs built the
reproduction themselves — a throwaway node --input-type=module -e snippet
constructing a session array with two adjacent expired entries — ran it, watched the prune return
the wrong array, and only then edited. That is the entire method in miniature, and the agent
reached for it unprompted when the environment refused to hand it evidence.
Twelve runs cost $5.77 in total, between $0.34 and $0.67 each. What twelve runs
on one bug cannot tell you is anything about a mechanism that isn't a known pattern — and
that is precisely where an unreproduced fix goes wrong, because pattern-matching is the fallback
and there is no matching pattern. So treat the rest of this post as advice for the cases my little
experiment could not stage, not as a claim that your agent is about to lie to you about a
thirty-line file.
Symptom disappearance is not a fix
The failure mode is worth naming precisely even when it did not fire, because when it does fire it
is invisible in exactly the way that matters: the fix looks right, the symptom is gone, and
the test passes. It has a family of forms, and they share one signature.
The change
What it looks like
What it actually did
The swallowed exception
try { … } catch { return null }
The bad state still exists; it now travels one layer further before doing damage
The widened guard
if (ms <= 0) return 'expired'
The invariant that was catching the bug has been deleted, so nothing catches it next time
The loosened assertion
toBeCloseTo instead of toBe; a tolerance that grew
The test now passes for both the right and the wrong answer
The retry
A loop, a sleep, a second attempt
A race turned into a slower race with a lower hit rate
The moved expectation
The fixture or the expected value edited to match current output
The bug is now the specification
The signature is the same in every row: the change lands where the symptom surfaced, not
where the state went wrong. In my bug, the symptom surfaces inside
humanize(), which is the one function in the file that is behaving correctly. A guard
there makes node bin/report.js print a clean report — and leaves
isLive() answering true for an expired session, which is an auth
decision, not a formatting one. One of the pressure runs said this out loud in its summary,
unprompted, while shipping a one-line fix:
"The same skip bug means isLive could have reported an expired session as live,
since it shares pruneExpired. […] if anything downstream made an auth or access
decision off isLive while this was in production, it was answering true for expired
sessions whenever two expired ones sat adjacent in the array."
That is the sentence you are trying to obtain from a debugging session. It is not "I fixed it" —
it is a claim about the blast radius of the mechanism, and it is checkable. So the first thing to
read in any bug-fix diff is not whether the change is correct but where it is. If
the fix sits at the throw site, ask why the state was wrong before it got there. That question
costs one turn and is the single highest-yield thing you can say in a debugging session.
The loop that works
Four steps in a fixed order, with one hard gate in the middle.
The gate is the whole design. Everything to its left is evidence; everything to its right is a
change you can evaluate against that evidence.
1. Reproduce. One command, deterministic, failing. If the bug needs state — a
row in a table, an expired token, a specific clock — the reproduction is the thing that
manufactures that state, not a description of it. This step is done when the agent has pasted
output it obtained by running something, not output it predicted.
2. Narrow. From the failing command down to the smallest call that still produces
the bad state. The instruction that does the work here is a distinction:
find the line that produces the wrong value, not the line that throws. Without it, the
search terminates at the stack trace, which is a report of where the program noticed, not where it
went wrong.
3. State the mechanism — before any edit. Two sentences: which line produces the
wrong state, and why. This is the gate, and its ordering is the point. An explanation written
before the change is a prediction you can check. The same words written after are a
rationalisation of a diff that already exists, and models are extremely good at producing those.
You are also, at this exact moment, the cheapest reviewer in the loop: you read two sentences and
decide whether they are a mechanism or a vibe. "There was a race condition" is a vibe.
"The loop index advances past the element that shifted into the vacated slot" is a
mechanism — it predicts which inputs fail and which don't, so you can test it.
4. Change, then verify against step 1. One edit, then the original failing command
passing. If the change is bigger than the mechanism warrants, that gap is where the next bug will
be introduced.
You do not need a framework for this. The whole protocol fits in a prompt, and it is the exact
text I used for arm B:
node bin/report.js crashes with "Error: negative TTL: -1800000".
Work in this order and do not skip a step:
1. Reproduce it. Run the command and paste the exact output.
2. Narrow it. Find the smallest call that produces the bad state —
not the line that throws, the line that produces the value that throws.
3. State the mechanism in two sentences before you edit anything:
which line produces the wrong state, and why.
Do not edit any file before this step.
4. Write a failing test that captures the mechanism, not the crash
message, and show it failing.
5. Only then fix it, and show the test passing.
If you use this often, it belongs in a slash command or a skill rather than your clipboard. And
keep it short: a debugging protocol that has grown to forty lines is competing for attention with
the actual bug, which is a bad trade in a window that is already
filling up with file reads.
The failing test is the contract
Step 4 is the one people skip, and it is the one that gives the word "fixed" a definition. Without
it, "fixed" means "the thing I was looking at stopped happening", which is satisfied by every row
in the suppression table above.
The rule that makes the test worth writing: assert the mechanism, not the message.
Watch how differently these two fail. A test written against the symptom —
test('report does not throw', () => {
assert.doesNotThrow(() => summary(sessions, NOW))
})
— passes the moment anyone wraps the call in a try, or softens the
humanize guard, or deletes the invariant. It is satisfied by the bug being hidden. A
test written against the mechanism cannot be:
That is a real test from one of the protocol runs, and the shape is what matters: it names the
input class that triggers the mechanism — adjacent expired entries — rather than the
string the crash happened to print. It fails on the old code for the right reason, and no guard,
catch, or tolerance can make it pass.
There is a sequencing detail worth insisting on. Make the agent show the test failing
before it writes the fix. A test authored after the fix has never been observed to fail,
which means you have no evidence it can. It is common — and completely silent — for such a test to
assert something the code did before the fix as well.
What an agent is genuinely better at than you
None of the above is a claim that you should debug and the agent should type. There is a real
division of labour here, and it is not the one people assume.
Hand to the agent
Why it wins
Reading an unfamiliar stack
Ten frames of a framework you have never opened, plus the code around each one, is thirty minutes for you and one turn for it. This is the strongest everyday case, and it is why a stack trace pasted raw beats a stack trace summarised.
Bisecting
Mechanical, tedious, and perfectly specified: run the command, classify good or bad, halve the range. Give it the exact test command and let git bisect run do the rest.
Scanning a large surface for a pattern
"Find every call site that assumes this returns a copy" is a search where the process is large and the answer is small — the exact shape that belongs in a subagent, so the reading happens in someone else's context window and yours receives the list.
Grinding logs
Correlating timestamps across three services at 2 a.m. is a task where human attention degrades and machine attention does not. Pipe it the log; do not summarise it first.
Enumerating hypotheses
Before you commit to a theory, "list six mechanisms that could produce this symptom in this code, ranked by how cheaply each can be ruled out" costs one turn and routinely contains the one you would not have considered.
Notice what these have in common: each is a search with a checkable result. The agent is
doing work whose output you can verify cheaply — a list, a commit hash, a set of call sites. That
is the category where delegating is close to free.
What it is worse at — and it is one thing
Everything that needs state you have and it does not.
The flaky CI job. The failure lives in a container you are not in, with a
different clock, different parallelism, and a different filesystem. An agent asked to fix
flakiness from the repo alone will reliably propose a plausible cause — test ordering, a shared
fixture, a timing assumption — and it will be plausible whether or not it is true.
The race. Reproduction is the entire problem, and it is stubbornly local: your
machine's core count, your event loop, your lock contention. The mechanism is a claim about
interleaving, which is exactly the claim a model can construct fluently without evidence.
The production-only failure. Data shape, scale, a config value, a version skew,
a customer with 40,000 rows where your fixture has three. The bug is in the difference between
environments, and the agent can only see one of them.
"It worked yesterday." The information is in the interval, not the code —
deploys, migrations, a dependency that moved, a certificate that expired.
The move in all four cases is the same, and it is not "debug it yourself". It is
bring the state to the agent, so the thing it lacks stops being missing. Paste the
failing CI run's full log rather than describing it. Give it the git log for the
interval instead of saying "recently". Hand it the production row that breaks, anonymised. Ask it
to write the script that reproduces the race under load — building reproductions is a coding task,
which is the thing it is good at, rather than a debugging task, which is the thing it is guessing
at.
And when the state genuinely cannot be transported, say so in the prompt and constrain the output
to match: "You cannot reproduce this. Do not change any code. Rank the candidate mechanisms and
tell me what evidence would separate them." An agent asked for hypotheses gives you a list you
can test. The same agent asked for a fix gives you a diff you cannot evaluate — and the diff will
look exactly as confident as the list would have been useful.
When to stop and read the code yourself
The honest rule has three triggers, and they all detect the same underlying condition — the agent
has stopped reasoning from evidence and started iterating on plausibility.
Two failed fixes in a row. Not two rounds of refinement — two changes that were
supposed to resolve it and did not. After the second, the window contains two wrong theories that
are still shaping every subsequent token, and the third attempt is drawn toward them. Start a new
session with what you learned, or read the code.
The diff is growing while the symptom stays. Defensive checks accumulating around
a failure is the visible form of guessing. A correct diagnosis usually makes the change
smaller: nine of my twelve runs replaced seven lines with one, and the three under time
pressure fixed it with a single added i--.
You cannot restate the mechanism in your own words. This is the one that catches
the dangerous case, because it fires while everything still looks fine. If the explanation reads
well and you could not defend it to a colleague, you do not have a diagnosis — you have prose.
Ask for the mechanism again in one sentence, with the specific input that triggers it. If the
second answer is not the same as the first, the agent is generating explanations rather than
recalling one.
"Read it yourself" is also not the only fallback. For a bug where being wrong is expensive, putting
the mechanism in front of a second, independently trained model is cheap and catches the
case where one model's plausible story is another's obvious error — the same
second-reviewer argument that applies to
diffs applies to diagnoses, and diagnoses are where a single confident narrator is most dangerous.
The short version
A bug prompt without a reproduction is a request for a guess. Give the command, the output, and the input — or say plainly that you cannot, and make building one step one.
Make the agent state the mechanism before it edits. Before is a prediction; after is a rationalisation.
Read where the diff landed, not just what it says. A change at the throw site is the signature of symptom suppression.
Write the failing test against the mechanism, not the message — and make it fail before the fix exists.
Delegate the searches: unfamiliar stacks, bisects, call-site sweeps, logs.
Don't delegate the state you hold. Transport it instead — logs, git ranges, real rows, a reproduction script.
Stop after two failed fixes, when the diff grows without the symptom shrinking, or when you cannot restate the explanation.
And do not assume the failure mode you fear is the one you have. On my twelve runs it never fired. Measure your own.
Where Backgrind fits
Nothing above needs a product — it is a prompt, a test, and the discipline to read a diff before
approving it. But debugging this way has a side-effect worth naming: it produces a lot of
waiting. Reproduce, narrow, explain, test, fix is five stops where the agent needs
something from you, and the honest way to work while an agent bisects a repository is to do
something else and be interrupted when it finishes.
That is the part Backgrind handles. It runs your real CLI in an always-on-top
overlay with a tab per session and a background daemon that keeps them alive when the window is
hidden, so the bisect you started can grind away while you work the other branch — and the tab that
needs a decision pings with an accent ring and a chime instead of sitting silently at a prompt.
Twelve headless runs is a fine way to gather data; one attended session per bug is how you actually
ship. See the loop in the live demo.
Frequently asked questions
What is the best way to prompt an AI agent to fix a bug?
Give it a reproduction, or make producing one the first step. A good bug prompt contains the exact command that fails, the exact output, and the input that triggers it. If you do not have a reproduction, say so explicitly and ask the agent to build one before it edits anything — that instruction is the whole difference between debugging and guessing.
Why is "fix this bug" a bad prompt?
Because it names a symptom and asks for a diff. It contains no reproduction, no definition of done, and no constraint on where the fix may land — so the cheapest way to satisfy it is to make the symptom stop appearing. That is how you get a swallowed exception, a widened tolerance, or a loosened assertion, all of which pass the test you were looking at.
Will an agent fix a bug it has not reproduced?
It can, and on a small textbook bug it often gets it right anyway. In 12 headless Claude Opus 5 runs on one real off-by-one bug, all 12 produced a correct root-cause fix, and 10 of the 12 executed the failing code before their first edit even when nothing in the prompt asked them to. The discipline matters most where reproduction is not available to the agent at all — a flaky CI job, a race, a production-only failure — because there the model has nothing to check its pattern match against.
How do I know an agent actually fixed the bug instead of hiding it?
Make "fixed" have a definition before the fix exists: a test that fails on the current code for the right reason and passes after. Write it against the mechanism, not the error message — a test that only asserts "does not throw" is satisfied by a try/catch. Then read where the diff landed. A fix at the line that surfaced the symptom, rather than the line that produced the bad state, is the signature of suppression.
What are AI agents genuinely better at than humans when debugging?
Reading an unfamiliar stack trace and the code around it, bisecting mechanically, scanning a large surface for every instance of a pattern, and grinding through logs. All of these are searches where the process is large and the answer is small — exactly the shape that suits a subagent with its own context window.
When should I stop and debug it myself?
When two fixes in a row have failed, when the diff is growing while the symptom stays the same, or when you cannot restate the agent's explanation of the mechanism in your own words. All three mean the same thing: the agent is no longer reasoning from evidence, and the next turn will produce another confident, plausible, wrong change.
Sources
Everything numeric in this post comes from runs I performed on 7 August 2026, not
from published benchmarks. Setup: a five-file Node repo (ESM, node:test, no
dependencies) containing one off-by-one bug — Array.prototype.splice called inside a
forward for loop — plus a four-test suite that passes on the buggy code because every
test prunes a single expired entry. Twelve runs of claude -p on Claude Code
2.1.223, default model, reported by the run JSON as
claude-opus-5[1m]; each run used --permission-mode acceptEdits with Bash,
Edit, Write, Read, Glob and Grep allowed, and a fresh copy of the repo. Four arms of three runs
(bare, reproduce-first protocol, no-reproduction-available, time pressure), prompts quoted verbatim
in the table above. Scoring: a held-out three-assertion test file, never present during any run,
copied in afterwards; "ran the code before first edit" was derived from the ordered tool-call
stream (--output-format stream-json) by checking whether any Bash call before the first
Edit or Write actually executed the module. Turn counts and dollar costs are the
num_turns and total_cost_usd fields of each run's result event; the total
was $5.77. The user-level CLAUDE.md on this machine was in scope for every run and
contains no debugging instructions; there was no project CLAUDE.md. Twelve runs against
one bug in one language is an anecdote with a denominator, not a benchmark — in particular it says
nothing about mechanisms that are not well-known patterns, which is exactly where I would expect an
unreproduced fix to fail. The taxonomy of symptom-suppressing fixes is drawn from practice, not from
these runs: none of the twelve produced one.