DEV Community

Cover image for Silent Failure #5: The Suite Was Green Because It Asserted the Bug
Renhua Wei
Renhua Wei

Posted on

Silent Failure #5: The Suite Was Green Because It Asserted the Bug

A tool-calling request went out of an OpenAI-compatible gateway with its envelope missing. The upstream answered 200, wrote a normal assistant message, and made zero tool calls. Routing was fine. Quota was fine. Health was fine. The provider's own test file was green too — twelve passing tests, zero failures — which is exactly why nobody looked.

The payload was supposed to look like this:

{ "type": "function", "function": { "name": "find", "parameters": { } } }
Enter fullscreen mode Exit fullscreen mode

and it left the gateway like this:

{ "name": "find", "parameters": { } }
Enter fullscreen mode Exit fullscreen mode

Same name, same schema, no type, no function key. Every server that reads tools[].function.name found nothing to read and quietly behaved as if the caller had supplied no tools at all.

The branch that lost the wrapper

A provider adapter rewrites tool names on the way out, and while it was there it also normalised regex patterns inside the parameter schemas. Two branches, three lines apart:

if (fn !== undefined) {
  const fnOut = { ...fn, name, parameters: completeRequiredSchema(fn["parameters"]) };
  return normalizeRe2Declaration(fnOut);                    // wrapper gone
}
if (custom !== undefined) {
  const customOut = { ...custom, name, format: completeRequiredSchema(custom["format"]) };
  return { ...rawTool, custom: normalizeRe2Declaration(customOut) };  // wrapper kept
}
Enter fullscreen mode Exit fullscreen mode

The custom branch rebuilds the outer object and puts the normalised declaration back inside it. The function branch returns the declaration and drops everything that used to wrap it. One line, and the difference is invisible unless you print the payload.

Why twelve tests stayed green

This is the part worth the post. The suite had two tests covering exactly this rewrite, and both of them read the result like this:

const tool = (payload["tools"] as Array<Record<string, unknown>>)[0]!;
expect(tool["name"]).toBe("find");
const params = tool["parameters"] as Record<string, unknown>;
Enter fullscreen mode Exit fullscreen mode

They reached for tool["name"] — the flattened shape. That is the shape the bug produces. The assertions were not merely agnostic about the envelope; they encoded its absence. Anyone reading that test would conclude the wire format was { name, parameters }, because the test says so in two places.

Measured on a clean checkout of main at 05ffff40, bun 1.4.4:

$ bun test test/providers/buddy-donor-gaps.test.ts
 12 pass
 0 fail
 23 expect() calls
Enter fullscreen mode Exit fullscreen mode

Green, on the commit where tool calling through that provider is broken.

The one change that turned the suite honest

I did not touch the source first. I changed the two assertions to read through the wrapper and added a shape test, then ran the same file again:

$ bun test test/providers/buddy-donor-gaps.test.ts
 10 pass
 4 fail
 24 expect() calls
Enter fullscreen mode Exit fullscreen mode

Two of those failures are the old RE2 assertions, now reading tool["function"]["parameters"]. Two are new envelope tests — one against the normaliser, one through the provider's CN pre-payload path. The failure text is the whole story:

expect(tool["type"]).toBe("function")
Expected: "function"
Received: undefined
Enter fullscreen mode Exit fullscreen mode

Four red tests, one undefined. That undefined is what the upstream had been receiving for however many releases.

Then the fix, and the numbers after it

-      return normalizeRe2Declaration(fnOut);
+      return { ...rawTool, function: normalizeRe2Declaration(fnOut) };
Enter fullscreen mode Exit fullscreen mode
$ bun test test/providers/buddy-donor-gaps.test.ts
 14 pass
 0 fail
 32 expect() calls
Enter fullscreen mode Exit fullscreen mode

Fourteen, not twelve: the two envelope tests are new, and the two RE2 tests still assert the same pattern rewriting — they just read it from the place the wire actually reads it from.

What the broad run was hiding

One file being green is not the same as the tree being green, so I ran every provider test:

$ bun test test/providers
 1644 pass
 18 skip
 13 fail
 1675 tests across 174 files
Enter fullscreen mode Exit fullscreen mode

Thirteen failures, and none of them about tools. They are database-backed suites calling getTestPool, and there is no Postgres in this environment. I did not take that on faith: I ran the same directory with my change stashed, diffed the two failure lists, and got the same thirteen test names before and after. That diff is the only reason I can say "pre-existing" instead of "probably pre-existing" — a claim like that is worth exactly the command behind it.

$ bun run typecheck
$ tsc --noEmit          # exit 0
Enter fullscreen mode Exit fullscreen mode

The general shape of this bug

Three things had to line up, and they line up often:

  1. A transform function returned the inner thing instead of the outer thing.
  2. The consumer of that payload treats a missing key as "feature not requested" rather than as an error — HTTP 200, no tool_calls, no log line.
  3. The test that covered the transform asserted the transformed value, not the value's position in the contract.

Number 2 is the reason the bug survived; number 3 is the reason nobody found it. Health checks, quota counters and telemetry all measured the request, not the response, so all of them were telling the truth about a request that arrived and was answered.

What I'd test first

  • Print the serialised payload, not the parsed object. JSON.stringify the outbound body in one test and assert on the raw string. {"type":"function","function":{...}} either appears in that string or it does not, and no amount of shape-drift in the surrounding code can make the assertion agree with a bug.
  • Read through the contract, never around it. If the wire format is tools[].function.name, the test reads tools[0].function.name. A test that reaches for tools[0].name is asserting a shape the server does not accept, and it will pass forever.
  • Add a negative assertion next to every positive one. In the same test: expect(tool["name"]).toBeUndefined(). That single line is what makes the flattened shape impossible to reintroduce silently — the next person who returns the inner object gets a red test instead of a support ticket that reads "the model ignores my tools".
  • Diff failure lists, not failure counts. "13 failures, probably the database" is a guess. Same 13 names with the change stashed and reapplied is evidence.

One question I would genuinely like an answer to, because I have only seen this from one side: if you are on bun 1.4.x or Node 22 and you have an outbound payload transform, do you assert on the serialised string or on the parsed object — and has a shape change ever slipped through the parsed-object version in your repo the way this one slipped through mine?

Top comments (10)

Collapse
 
rushdeploy profile image
Rishabh Mishra •

I assert on the serialised string for outbound payloads: it catches exactly this kind of flattened envelope, where the parsed object still looks plausible. I still keep parsed-object assertions too, but only for the values inside tools[0].function, never for the envelope itself.

Collapse
 
dafili profile image
Renhua Wei •

Serialised-string assertions are the right call here, with one caveat: pin the key order or run a stable stringify, otherwise a formatting change fails a payload that is actually fine. Add one structural check next to it — JSON.parse(out).tools[0].function === undefined — because that names the exact flattening and the failure message tells you what broke instead of just that the string changed.

Collapse
 
rushdeploy profile image
Rishabh Mishra •

Fair point, I should have said that plainly. I use a stable stringify with sorted keys for the envelope check, and I keep the negative assertion alongside it so a flattened shape fails loudly instead of passing quietly.

Thread Thread
 
dafili profile image
Renhua Wei •

Then make that negative assertion prove it can fail: mutate one value inside the golden string with sed -i 's/"type":"function"/"type":"function_v2"/' test/fixtures/envelope.json and run only that file. If it stays green, the sorted-key comparison is being matched against a stale fixture and neither assertion is load-bearing.

Collapse
 
eye_java_420f1faa10ae8b86 profile image
Nan •

The sed-one-value trick deserves to be a permanent CI check rather than a one-off demo. For every golden payload, keep a corrupted twin next to it in the repo: same shape, one field deliberately wrong. The test suite asserts both directions: the real fixture passes, the twin fails. If a refactor ever makes the twin pass, the assertion is no longer testing what you think it tests.

The twin has to live in version control with the test, not in a scratch file. A twin that lives only in your head or in a gist goes stale the moment the schema drifts. In CI the pair can be generated from one template with the corruption applied programmatically, so the twin updates when the schema updates.

This is the part mutation frameworks charge for, but for one golden payload a hand-written twin and two assertions are enough.

Collapse
 
dafili profile image
Renhua Wei •

Then make the twin fail on the field, not on the parse: expect(() => assertEnvelope(twin)).toThrow(/tools\[0\]\.type/) — a twin that dies with Cannot read properties of undefined is a green tick for a check that never ran. Generate both fixtures from one template (makeFixture({ mutate: ['tools[0].function.type', undefined] })) so the corruption tracks the schema instead of drifting in its own file, and run the pair with bun test test/providers/buddy-donor-gaps.test.ts -t envelope in pre-push.

Collapse
 
sebastian_gedda_08bcd28f8 profile image
Sebastian Gedda •

"The assertions encoded its absence" is such a good way to put it. Same trap shows up with hand-written mocks on the consumer side: if the fixture is written to match what the code produces, the test agrees with the bug forever. The negative assertion tip is great. The other thing that's helped me is keeping at least one fixture captured from the real upstream, so the contract shape comes from the wire and not from whoever wrote the test. And re-record it now and then and diff against the stored version, so you catch when the real API drifts away from your mock.

Collapse
 
dafili profile image
Renhua Wei •

A captured-from-wire fixture only stays honest if the re-record step is allowed to fail: write the fresh capture to resp.new.json and run diff <(jq -S 'paths|join(".")' stored.json) <(jq -S 'paths|join(".")' resp.new.json) || exit 1, so drift is a red job instead of a silently overwritten fixture. Re-record on a clock, not on demand — one capture per endpoint every 30 days, and if two successive captures diff clean, suspect the recorder's cache before trusting the green.

Collapse
 
nexusverify profile image
Nexus Verify •

The detail that stays with me: the provider's own suite was green because the test had quietly encoded the broken envelope as the expected one. A test that mirrors the implementation's assumption isn't testing the contract — it's a snapshot of the bug, and it will pass forever.

The discipline that catches this class is a negative control at the boundary. For every "well-formed input works" test, there should be one that sends the malformed shape on purpose — envelope missing, key dropped — and requires the far side to reject it loudly instead of answering 200 with silence. And periodically: break the thing deliberately and watch the suite. If nothing turns red when you remove the "type" field by hand, the suite was never measuring the contract in the first place.

Green is only evidence if the test was capable of being red.

Collapse
 
dafili profile image
Renhua Wei •

Two assertions would have caught it and still read as normal tests: expect(payload.tools[0].type).toBe('function') and expect(payload.tools[0].function.name).toBe('find'). On the broken commit 05ffff40 the first one throws before the second ever runs; on main both pass. If a test file never mentions function as a key, treat that file as untested, not as coverage.