← All posts

Guide

Debugging with an agent: reproduce first, or you're just editing code

Debugging with an agent: reproduce first, or you're just editing code

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:

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:

ArmThe promptWhat it tests
A — bare"node bin/report.js crashes with … Fix this bug."The prompt everyone actually types
B — protocolSame crash, plus five numbered steps: reproduce, narrow, state the mechanism before editing, write a failing test, then fixWhether 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 pass2 / 32 / 39–13
B — reproduce-first protocol3 / 3 pass3 / 33 / 38–16
C — no reproduction available3 / 3 pass3 / 33 / 313–15
D — ship in ten minutes3 / 3 pass2 / 30 / 36–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 changeWhat it looks likeWhat it actually did
The swallowed exceptiontry { … } catch { return null }The bad state still exists; it now travels one layer further before doing damage
The widened guardif (ms <= 0) return 'expired'The invariant that was catching the bug has been deleted, so nothing catches it next time
The loosened assertiontoBeCloseTo instead of toBe; a tolerance that grewThe test now passes for both the right and the wrong answer
The retryA loop, a sleep, a second attemptA race turned into a slower race with a lower hit rate
The moved expectationThe fixture or the expected value edited to match current outputThe 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.

no file is edited left of the gate 1 · REPRODUCE a command that fails 2 · NARROW smallest failing call 3 · MECHANISM stated, in writing GATE 4 · CHANGE one edit verify against step 1 — the same command, now passing If step 3 cannot be written, the loop restarts at step 2. An explanation produced after the edit is a justification, not a diagnosis.
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:

test('pruneExpired drops adjacent expired sessions', () => {
  const sessions = [
    { id: 'a', expiresAt: NOW + 600_000 },
    { id: 'b', expiresAt: NOW - 60_000 },
    { id: 'c', expiresAt: NOW - 1_800_000 },
    { id: 'd', expiresAt: NOW + 600_000 },
  ]
  assert.deepEqual(pruneExpired(sessions, NOW).map((s) => s.id), ['a', 'd'])
})

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 agentWhy it wins
Reading an unfamiliar stackTen 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.
BisectingMechanical, 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 logsCorrelating 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 hypothesesBefore 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.

  1. 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.
  2. 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--.
  3. 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.