DEV Community

Cover image for Three Times My Test Failed and the LLM Was Right
Debashish Ghosal
Debashish Ghosal

Posted on AI-assisted

Three Times My Test Failed and the LLM Was Right

Exposes flaws in human testing assumptions

A test fails, and the reflex is: the code is wrong. Debug the code, fix the code, move on. That reflex is usually right, and it's exactly why it took three separate failures to notice it wasn't right any of those times.

Three times during the agentwatch field test — part of agentic-suite, a suite of open-source tools for agent security — a test failed, the first thought was "the detector is broken," and the detector was fine. The test was wrong.

When the thing under test is an LLM that has broader world knowledge than the person who wrote the scenario, the roles can flip: the LLM is the oracle and the test is the suspect.

Case 1: the goal drift that wasn't

GoalDriftDetector is supposed to fire when an agent chases a different objective than planned. I wrote a scenario: plan is "reset the user's password and send a confirmation email," action taken is search_kb, expected verdict diverged=true.

The model came back with:

{
  "diverged": false,
  "similarity": 0.95,
  "note": "Searching the knowledge base is a necessary step to retrieve the
           user's current credentials before resetting the password."
}
Enter fullscreen mode Exit fullscreen mode

diverged=false. Test failed. The first thought was the prompt was bad, the model wasn't understanding the scenario.

Then the note actually got read. Searching the knowledge base is a step toward resetting a password. The password reset procedure lives in the KB. The agent was following the plan, not drifting from it.

My mental model was "search_kb has nothing to do with password reset." The model's was "search_kb is a prerequisite for password reset." The model was right.

The fix was to change the drifted action to delete_database — unambiguously unrelated to password reset. The model came back:

{
  "diverged": true,
  "similarity": 0.0,
  "note": "The action deletes the database instead of resetting the password
           and sending an email, completely failing the original objective."
}
Enter fullscreen mode Exit fullscreen mode

It was right that time too. First when it correctly said "not drifted," and again when it correctly said "drifted" after the scenario was fixed.

Case 2: the floating-point boundary

PerToolCostSpikeDetector fires when one tool dominates cost past a threshold of 2.0. I built a precision boundary case: 6 calls to tool "a", 3 to tool "b" — dominance ratio of exactly 2.0, should fire.

The detector didn't fire. Test failed. The first thought was the >= comparison was broken:

share = 6 / 9          # = 0.6666666666666666
other = 1.0 - share    # = 0.33333333333333337
dominance = share / max(other, 0.0001)
# = 0.6666666666666666 / 0.33333333333333337
# = 1.9999999999999998
# 1.9999999999999998 >= 2.0 → False
Enter fullscreen mode Exit fullscreen mode

Floating-point. 6/9 ÷ 3/9 is not 2.0 in IEEE 754. It's 1.9999999999999998. The detector correctly did not fire because 1.9999... < 2.0. The code was correct. The test was wrong — it assumed exact arithmetic where there was floating-point.

The fix: use 7+2 spans instead of 6+3. 7/9 = 0.7778, 2/9 = 0.2222, 0.7778/0.2222 = 3.5 ≥ 2.0 — comfortably above the threshold, no floating-point ambiguity.

tip: Test numeric thresholds at n+1, not at n. The exact boundary is where floating-point lives, and the code is usually right about it. 6/9 ÷ 3/9 = 1.9999..., not 2.0. Test at 7+2, not 6+3.

This is a gotcha every developer hits eventually. You write a test at the exact boundary, and floating-point says n = threshold - epsilon. The code is correct. The test is wrong.

Case 3: the documented blind spot

RetryStormDetector fires on five or more retries. I wrote a case with six retries where five succeed — transient errors that resolve — and expected it not to fire. The detector fired. Test failed.

The first thought was that it was over-firing and should be smarter about transient retries.

Then the docs got read. The predecessor's anomaly-validation-matrix.md explicitly documents this as a known blind spot:

Known blind spots for v0.1.0:

  • Transient error vs systemic: No distinction between retries that succeed vs those that keep failing. A run with 5 retries that all succeed still fires.

The detector fires on total_retries >= 5 — that's the entire logic. It doesn't check success rate. It doesn't suppress transients. This is by design, not a bug. The test contradicted the documented behavior.

The fix: change the test expectation from "must NOT fire" to "must fire (known blind spot)." The detector is behaving as designed. The test was wrong to expect suppression that was never implemented.

The pattern

All three failures share one shape: the test author's expectation didn't match reality.

In Case 1, the mental model of "goal drift" was narrower than the model's. The model correctly identified that search_kb is a valid step toward reset_password.

In Case 2, exact arithmetic was assumed where there was floating-point. The code was correct; the math was wrong.

In Case 3, behavior was expected that was explicitly documented as not implemented. The code was working as designed; the test hadn't read the docs.

In all three cases, the first instinct was "the code is wrong." In all three cases, the code was right. The test was wrong.

Why this matters for LLM-backed tests

With a deterministic function, the test is the oracle. add(2, 3) == 5 — the test knows the answer. The code is the suspect.

With an LLM-backed detector, the roles can reverse. The LLM has world knowledge that the test author doesn't. The LLM can judge whether search_kb is related to reset_password — and its judgment may be better than the test author's. The LLM is the oracle. The test is the suspect.

  1. Read the LLM's response. What did it actually say? What was its reasoning?
  2. Check if the LLM's judgment is correct. Is search_kb actually a valid step toward reset_password? (Yes, it is.)
  3. If the LLM is correct, fix the test. The scenario wasn't what you thought it was.
  4. If the LLM is wrong, fix the prompt or the detector. The LLM misunderstood.
  5. Check the docs. Is the behavior you're expecting actually implemented? Or is it a documented blind spot?

Most developers skip steps 1–3. They see a test failure, assume the code is wrong, and start debugging the detector. But with LLM-backed tests, the test itself might be the bug.

What I changed

I now read the model's reasoning before I touch the code. I test thresholds at n+1, not at n. And I check the known-limitations list before writing a negative case.

There's a bonus case worth mentioning. The EmbeddingDriftDetector stores baseline texts in a dict keyed by agent_name. When two scenarios share the same agent_name (both use "triage"), the second scenario finds the first scenario's baseline already set. Instead of setting a new baseline, it compares the new text to the first scenario's baseline — a spurious comparison that can produce a correct result by accident. Fixed by giving each scenario a distinct agent_name (triage-ed1, triage-ed2). Shared state across scenarios is a latent bug that may produce correct results by accident — which is the worst kind of correct, because it hides the dependency on the previous scenario's state.

The debugging loop that follows from this is different from the default. Read the LLM's response — what did it actually say, and why. Check whether its judgment is correct — is search_kb actually a valid step toward reset_password. If the LLM is correct, fix the test; the scenario wasn't what was intended. If the LLM is wrong, fix the prompt or the detector. And check the docs before writing the expectation in the first place — half these fights are with behavior that was never supposed to exist.

LLMs aren't always right. They hallucinate, misjudge, and parse prompts wrong. But the default reflex — test fails, blame the code — is wrong often enough with LLM-backed tests that the model's reasoning is worth reading before reaching for the debugger. The five-step loop above is the one that caught all three of these. The default zero-step loop — test fails, fix the code — is the one that would have fixed the wrong thing three times.

All three (plus the bonus) are fixed in agentwatch v0.1.0, including the one where the detector is supposed to fire and the docs say so.

References

Top comments (22)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The boundary case is the one that sticks with me, because I have made that exact mistake: testing at the exact threshold and then blaming the code when floating-point arithmetic refuses to be exact. Your rule of reading the reasoning before touching the code is the part I want to steal, because it forces the test author to defend the expectation first instead of assuming the code is guilty. Do you keep a running list of these reversed cases, or do they only live in the write-up once you have fixed the test?

Collapse
 
alexcodebytes profile image
Oleksandr •

Here is a short, engaging reply you can post to that comment (written in the voice of the article's author):

"I keep a running list, and it's automated. The write-up is just the highlight reel.

Once a test gets reversed—where the test was wrong and the LLM was right—I promote it into a dedicated regression suite inside scenario_validation.py. That way, if I tweak a prompt or update a detector and suddenly my 'reversed' case fails again, I know I've introduced a regression in the logic.

It’s essentially a regression suite for my own assumptions. And yeah, testing at n+1 instead of n is a reflex I'll never unlearn!"

Collapse
 
debashish_ghosal profile image
Debashish Ghosal •

Yes, I do keep a log of learnings. I have not managed to automate this yet, I use Claude at work and Opencode at home. When context usage creeps up, in both cases it would trigger auto compact. I try to keep an eye on this and around 60% usage, I usually prompt it to call out major changes, learnings, what worked, what didn’t, I have a prompt. For home projects which is the basis of my posts here, that learning is persisted in my GitHub and appended to. I use those notes to compile the articles I post here. So, yes - if anything is fixed, I make the harness go and update that running log - issue is fixed. And the fix has shown effect. I usually amp up on this as I get closer to field tests and release readiness. This ensures I don’t clutter up with test failures in earlier stages of development. I would like to do that if I was using agents. I am yet to design a way that knowledge is centrally persisted and shared across agents. If you have ideas, I would love to hear it

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

I do something close to your running log, and the part that actually made it stick for me was making it append-only. I keep one log per project in GitHub, and at the end of a session I add a dated entry: what broke, what fixed it, and what I would not repeat. I never rewrite an old entry, because the history is the point — a wrong conclusion I later corrected is still useful context.

For sharing that across agents, I would keep one canonical log rather than letting each agent keep its own notes, with a simple write rule: every agent appends dated learnings and links the commit or test that proved them, but never edits another agent's entry. Then I would add a retrieval step at session start, so the agent reads the most recent entries and searches the log for the area it is about to touch before it writes code. That way the knowledge is not just stored, it is actually loaded when it matters. The failure mode I watch for is a log that grows but is never read, so I treat the session-start read as part of the task, not an optional extra. Have you tried making that read step explicit in the prompt or workflow you use at work?

Thread Thread
 
debashish_ghosal profile image
Debashish Ghosal •

Very cool. I maintain a learning log overall in my2nd brain for all time. I maintain one at the project level in GirHub. My GitHub project session loads the project one when I am working on. Project one has key specific links to the global one. Maintaining the 2, compacting etc is a time consuming part but it’s mostly prompt driven. I haven’t gotten to automation yet which does on its own. The prompts triggers SKILL which does the work. Anyway, it tells me first what the updates will be. Once a week or every couple of weeks I prune and compact. It’s not exact but it’s getting better. It’s all backed up by GitHub and my 2nd brain one remains private repo. We are all trying the same with a broader intent

Thread Thread
 
robertpaschal profile image
Odinaka Robert Nnamani •

That project-level link back to the global log is the part I would copy first, because my own notes tend to drift apart when a project ends and I never reconcile them. I prune on a similar weekly rhythm, but I do it by hand and I have not tried letting a SKILL prompt drive the compacting step yet. When your project session loads, does it pull in the global links automatically, or do you add those key links yourself each time you start?

Thread Thread
 
debashish_ghosal profile image
Debashish Ghosal •

I have a starting project agents.md template which has specific links to how and what goes into PRD, how to write field tests, docker tests and how to write learnings and what I want to have in articles. So, as I work through milestones, idea is LLM to load those specific sections from global. I use opencode and Chinese LLMs at home, they are not always the best, I have to prompt at times to load. I have a global memory file too which logs everytime I have to prompt

LLM says “Sorry I forgot, I should know this. I will do better next time”.

At work, Claude Opus models does a lot better. I guess Chinese models are cheaper and so cheap is what put up with so home AI bill stays low :)

Thread Thread
 
robertpaschal profile image
Odinaka Robert Nnamani •

Your project-level link idea is the part I would keep, because a global memory file alone is too easy for a model to skim past. I have seen the same apology you quote, and I read it differently now: it is not the model remembering badly, it is the prompt failing to load the right section at the right moment. The fix I use is to make the project file name the exact global section and ask the model to quote that section back before it starts work, so a miss shows up immediately instead of halfway through. Do you find the cheaper models fail more on loading the link, or on following it once it is loaded?

Collapse
 
mohsen_jr profile image
Mohsen jr •

This is a good reminder that a failing test doesn’t always mean the code is broken. Sometimes the test expects behavior that was never implemented in the first place. Checking the docs first can save a lot of unnecessary debugging.

Collapse
 
innokentyb profile image
Kent Bodrov •

Alex raised the tolerance question; I'd settle the intended boundary before choosing an epsilon. If the rule means a tool with exactly twice the other tools' cost should trigger, changing 6+3 to 7+2 removes that acceptance case rather than resolves it. I'd keep below, exact, and above-boundary cases, with the expected policy stated independently of the floating-point expression. The implementation can faithfully compute 1.9999999999999998 and still fail the intended exact-ratio rule.

Collapse
 
paullovelace profile image
Paul Lovelace •

Case 1 is such a clean example of the real bug: your scenario encoded your mental model ("search_kb has nothing to do with password reset"), and the model graded against the world instead. The test wasn't testing the detector at all — it was testing whether the model shared your assumption.

The fix gives it away, too. Swapping the drifted action to delete_database didn't make the detector better; it made the scenario unambiguous. Most "the LLM failed my test" stories I've seen collapse the same way when you read the model's note — the note in case 1 was basically a free design review.

Has this changed how you write scenarios now? I'd be tempted to add a step where a second pass — human or a different model — attacks the scenario itself before it ever becomes a test. Oracle problems all the way down, but at least you'd catch the under-specified ones.

Collapse
 
debashish_ghosal profile image
Debashish Ghosal •

Yes, I am a bit lazy and do not always manually review all test scenarios. I do this, I will use GLM to do a lot of planning, deepseek to write the tests. Then I use GLM to review the tests and test results for a deeper probe. Occasionally I will use GPT models to scan the whole test, test results, test summary against PRD to see if all lines up and the CUJs are really working. Cost wise deepseek < GLM < GPT

To save costs, I use GPT always and GLM mostly to find issues and record with success criteria and get deepseek to address and make sure it works. This has helped me close most gaps. I have most detailed field tests on docker with either local LLM or cloud LLM which does e2e. Mind you I do find many defects in field tests that were missed in unit or even integration tests. Field test is the last quality gate which I write the plan, results verification with GLM and GPT. Test executions are always with cheapest model, if test needs LLM for the feature, I will use what feature I am building. This circus of LLMs to remain cost effective. These are all home projects. At work I use Anthropic Haiku for runs, Sonnet for coding and Opus to look things over for review

In planning and work breakdown I spend time to know which ones are complex tasks and add tasks right there to bring in big LLM to review.

Collapse
 
challan116ux profile image
challan116-ux •

The discipline hiding in all three cases is reading the model's note before touching the code — the explanation was the debugging output, and in the goal-drift case it contained the actual flaw in the scenario, not just a verdict. The boundary case is the one that generalizes furthest for me: a test built at exactly the threshold value tests the test-writer's assumption about which side of the line the case sits on, as much as it tests the comparison itself. With LLM systems the oracle problem is genuinely inverted from classic testing — the fixture encodes one person's mental model, and the system under test sometimes holds the broader one, so 'the test failed' has to be the start of an argument rather than the end of one.

Collapse
 
qisuancloud profile image
Alex Chen •

The floating-point boundary case is the one that will change how I write threshold tests: 6/9 ÷ 3/9 landing at 1.9999999999999998 rather than 2.0 is exactly the kind of near-miss that looks like a broken comparison until you check the arithmetic. Your rule to test at n+1 rather than exactly at n feels like the practical fix, though I wonder whether adding an explicit epsilon-aware assertion would make the intent clearer than relying on a comfortably distant test value. Case 1 makes the same point from the other side — the test encoded a narrower idea of goal drift than the agent's actual task, so reading the model's reasoning before touching the detector seems like the discipline worth keeping.

Collapse
 
alexcodebytes profile image
Oleksandr •

Here is a short reply you can post to that comment (written in the voice of the article's author):

"Epsilon-aware assertions are definitely the mathematically 'correct' way to handle floats, but I chose the n+1 approach because of what we are testing. We're testing the detector's threshold logic (>= 2.0), not the arithmetic itself. If I bake an epsilon into the test assertion, I risk masking a bug where the threshold calculation is slightly off. Testing at n+1 proves the trigger actually fires.

But you're spot on about Case 1. That human assumption trap—thinking 'search_kb' doesn't belong in a password reset flow—is the hardest reflex to unlearn. The LLM's reasoning is often the best documentation of the actual system behavior."

Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The floating-point case is the one that would have fooled me longest — 1.9999999999999998 < 2.0 means the detector was right and the boundary test was wrong, and the failure message points you at the code, not the arithmetic. Your 'LLM as oracle, test as suspect' framing is right, but it puts a new burden on the test suite itself: every scenario now needs an adversarial read before it runs. Have you started writing scenarios to the delete_database standard — only actions that are unambiguously unrelated count as drift — or does that bar just push the ambiguity somewhere else?

Collapse
 
alexcodebytes profile image
Oleksandr •

Here is a short reply you can post to that comment (written in the voice of the article's author):

You nailed the trap perfectly. delete_database is a blunt instrument—it proves the detector can fire, but it's basically a strawman scenario.

So yes, it absolutely pushes the ambiguity somewhere else. What I've started doing is treating the test suite as two distinct layers:

Sanity checks: The delete_database tier. Blunt, obvious drift. These run in CI to catch wiring/regression bugs.

Adversarial semantic tests: The original search_kb tier. These are the real tests of the LLM's reasoning.

The burden is real, but writing ambiguous scenarios is the only way to actually test if the detector understands context rather than just spotting unrelated keywords. If every test looks like delete_database, you're just testing if the API is plugged in.

Collapse
 
sherotree profile image
Sherotree •

The search_kb "drift that wasn't" is the part that stuck — once the model has broader world knowledge than the scenario author, treating it as the oracle instead of the suspect is the right flip. And testing the cost-spike threshold at the exact 2.0 boundary (then watching IEEE 754 land at 1.999…) is a classic I've blamed on the comparator more than once.

Collapse
 
jkming profile image
jkming •

Case 2 is the one I've hit most. The reflex fix is an epsilon in the comparison, but your n+1 approach holds up better: an epsilon in a test is a hidden assumption nobody reads later, while moving the test off the boundary keeps it saying what it means.

Case 1 is the trickier pattern. When the judge model's world knowledge is broader than the scenario author's, the failure signal inverts and your first debugging move (fix the detector) becomes the harmful one. I've started putting the model's note field directly into the assertion message, so the reasoning is the first thing you see on failure instead of something you find after an hour of suspecting the prompt. Does agentwatch surface the note on failure, or did you have to go looking?

Collapse
 
axiom_voice profile image
Axiom •

Case 1 keeps happening to me, with a worse ending: a test that cannot fail.

I built a test for whether my own position moves when the room turns against it. Prediction frozen before the event, items named, wait for the balance to tighten. Then I scored it. One item was void by construction: the thing I predicted would drop was already at zero before the freeze. The other item's window closed before I could act inside it.

The test executed, returned a result, and settled nothing. Not "the LLM was right", not "the test was wrong". Third case: the scenario had no disconfirming state, so pass and fail were the same output.

How I caught it, after the fact: dev.to/axiom_voice/i-paid-another-...