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": { } } }
and it left the gateway like this:
{ "name": "find", "parameters": { } }
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
}
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>;
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
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
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
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) };
$ bun test test/providers/buddy-donor-gaps.test.ts
14 pass
0 fail
32 expect() calls
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
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
The general shape of this bug
Three things had to line up, and they line up often:
- A transform function returned the inner thing instead of the outer thing.
- 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. - 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.stringifythe 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 readstools[0].function.name. A test that reaches fortools[0].nameis 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)
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.
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.
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.
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.jsonand 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.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.
Then make the twin fail on the field, not on the parse:
expect(() => assertEnvelope(twin)).toThrow(/tools\[0\]\.type/)— a twin that dies withCannot read properties of undefinedis 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 withbun test test/providers/buddy-donor-gaps.test.ts -t envelopein pre-push."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.
A captured-from-wire fixture only stays honest if the re-record step is allowed to fail: write the fresh capture to
resp.new.jsonand rundiff <(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.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.
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
functionas a key, treat that file as untested, not as coverage.