Skip to content

Commit 0a022e9

Browse files
committed
style(website): what-fixture-replay-cant-catch into Brian's 2026 register
1 parent a54cc61 commit 0a022e9

1 file changed

Lines changed: 75 additions & 40 deletions

File tree

apps/website/content/blog/2026-08-29-what-fixture-replay-cant-catch.mdx

Lines changed: 75 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,23 @@ featured: false
88
draft: false
99
---
1010

11-
Agents are hard to test end to end for one boring reason: the model doesn't return the same thing twice.
12-
Ask it the same question in two runs and you get two different sentences, two different orderings, sometimes a tool call and sometimes not. Write an assertion against that and you've written a coin flip.
11+
Agents are hard to test end to end for one boring reason: the model does not return the same thing twice.
12+
Ask it the same question in two runs and you get two different sentences, two different orderings, sometimes a tool call and sometimes not.
13+
Write an assertion against that and you have written a coin flip.
1314

1415
The usual fix is to stop calling the model.
15-
You capture its responses once, save them to disk as _fixtures_, and _replay_ them on every run — same request in, same bytes back, forever. The tests go deterministic, CI stops spending money on tokens, and the agent under test never knows it's talking to a recording.
16+
You capture its responses once, save them to disk as _fixtures_, and _replay_ them on every run — same request in, same bytes back, forever.
17+
The tests go deterministic, CI stops spending money on tokens, and the agent under test never knows it is talking to a recording.
1618

17-
We test our whole demo fleet that way. This post is about the bill, because replay isn't free and the charge doesn't show up where you'd look for it.
19+
We test our whole demo fleet that way.
20+
This post is about the bill, because replay is not free and the charge does not show up where you would look for it.
1821

19-
Here's the shape of it. Every deterministic harness buys its determinism by deleting a dimension of the real thing.
22+
Here is the shape of it.
23+
Every deterministic harness buys its determinism by deleting a dimension of the real thing.
2024
Ours deletes _time_ — deliberately, with the reason written in the source — and one specific class of bug vanishes along with it.
2125

22-
So the interesting question about a harness isn't whether it's green.
23-
It's which dimension you deleted, because that's the list of bugs it can't report.
26+
So the interesting question about a harness is not whether it is green.
27+
It is which dimension you deleted, because that is the list of bugs it cannot report.
2428

2529
## Where do you put the mock?
2630

@@ -44,22 +48,25 @@ Everything above that line is the real thing.
4448
A real Angular app, the real streaming transport, a real Python server, real graph nodes with their edges and conditional routing.
4549
The model is the only stand-in.
4650

47-
Let's take the alternative.
48-
A test that mocks the agent at the app boundary proves your component renders what you handed it. It cannot tell you that your graph's conditional edge routes correctly, that your transport merges deltas in the right order, or that a tool call round-trips.
51+
The alternative is worth naming.
52+
A test that mocks the agent at the app boundary proves your component renders what you handed it.
53+
It cannot tell you that your graph's conditional edge routes correctly, that your transport merges deltas in the right order, or that a tool call round-trips.
4954
Push the seam out to the provider and all of that is under test, because none of it was replaced.
5055

5156
Replacing the model — and only the model — is also what makes it cheap enough to run everywhere: no API spend, no rate limits, no coin flips.
5257
We have 50 fixture files holding 129 entries across 34 apps — 32 cockpit capabilities and two example apps — all on the same harness.
5358

54-
This is the outer tier. For in-process fakes at the unit level, the [testing guide](/docs/langgraph/guides/testing) covers `provideFakeAgent()`, `mockLangGraphAgent()`, and `MockAgentTransport`, which are a different tool for a different job.
59+
This is the outer tier.
60+
For in-process fakes at the unit level, the [testing guide](/docs/langgraph/guides/testing) covers `provideFakeAgent()`, `mockLangGraphAgent()`, and `MockAgentTransport`, which are a different tool for a different job.
5561

5662
## What does a fixture match on?
5763

5864
The shape of the request — and the order you list the entries decides which one wins.
5965

6066
A fixture file is a list of entries, and each one is a pair: a `match` block describing which request it answers, and the response to hand back when a request fits.
6167

62-
The obvious discriminator is the user message, but there are richer ones: a parent LLM's first call and its continuation after a tool round carry the same user message. Something has to tell them apart.
68+
The obvious discriminator is the user message, but there are richer ones: a parent LLM's first call and its continuation after a tool round carry the same user message.
69+
Something has to tell them apart.
6370

6471
That something is `hasToolResult`, and matching is first-match-wins:
6572

@@ -78,13 +85,14 @@ The continuation arrives carrying the same user message, matches the looser entr
7885
The model calls the tool. The result comes back. It matches the looser entry again.
7986
Nothing errors. The assistant simply never finalizes.
8087

81-
For me that's the sharpest thing about fixture files: they're data, so they look inert, but the ordering is executable.
88+
For me that is the sharpest thing about fixture files: they are data, so they look inert, but the ordering is executable.
8289

8390
## What did we trade away?
8491

8592
The streaming, on purpose.
8693

87-
The mock is constructed with a chunk size large enough that every response arrives in one or two server-sent events. Here's the whole note that sits above it:
94+
The mock is constructed with a chunk size large enough that every response arrives in one or two server-sent events.
95+
Here is the whole note that sits above it:
8896

8997
```typescript
9098
// Use a large chunkSize so each response arrives in 1-2 SSE deltas. This
@@ -99,77 +107,104 @@ The mock is constructed with a chunk size large enough that every response arriv
99107
const mock = new LLMock({ port: 0, chunkSize: 4096 });
100108
```
101109

102-
A real rendering bug, but a *streaming* one, and it was making structural assertions flaky for reasons that had nothing to do with what they asserted. So the timing went away and the property moved down a tier.
110+
A real rendering bug, but a *streaming* one, and it was making structural assertions flaky for reasons that had nothing to do with what they asserted.
111+
So the timing went away and the property moved down a tier.
103112

104-
I think that's the right trade, and the reason isn't that the flakiness went away — it's that the property didn't.
113+
I think that is the right trade, and the reason is not that the flakiness went away — it is that the property did not.
105114

106115
The tempting alternative is to replay the recorded chunk boundaries instead of re-chunking, so you get the timing back for free.
107-
That doesn't buy what it looks like it buys. Faithful boundaries make the fence failure *deterministic* rather than absent — the parser bug is still there, and now every structural assertion in the suite fails for a reason none of them are about.
116+
That does not buy what it looks like it buys.
117+
Faithful boundaries make the fence failure *deterministic* rather than absent — the parser bug is still there, and now every structural assertion in the suite fails for a reason none of them are about.
108118

109-
Now the part that's easy to get wrong without going looking, and it's the useful half.
119+
Now the part that is easy to get wrong without going looking, and it is the useful half.
110120

111-
That 4096 is a _default_, not a law. A second harness serves our two example apps, and its version of that comment says so outright — ordinary fixtures get the big chunk size, and *targeted streaming regressions opt into smaller per-fixture chunks*. Those fixtures set chunk sizes of three, four, six, twenty-three, thirty-six, with latencies from 25 to 750 milliseconds. There are e2e tests over there that sample the mid-stream DOM while it renders.
121+
That 4096 is a _default_, not a law.
122+
A second harness serves our two example apps, and its version of that comment says so outright — ordinary fixtures get the big chunk size, and *targeted streaming regressions opt into smaller per-fixture chunks*.
123+
Those fixtures set chunk sizes of three, four, six, twenty-three, thirty-six, with latencies from 25 to 750 milliseconds.
124+
There are e2e tests over there that sample the mid-stream DOM while it renders.
112125

113-
So "we deleted time" is too tidy. What we did was delete it by default and buy it back per fixture, in the places somebody decided it was worth the cost.
126+
So "we deleted time" is too tidy.
127+
What we did was delete it by default and buy it back per fixture, in the places somebody decided it was worth the cost.
114128

115-
Which turns the question into a better one. Not *what did the harness give up*, but *which tier opted back in* — because the tier that didn't is the one flying blind.
129+
Which turns the question into a better one.
130+
Not *what did the harness give up*, but *which tier opted back in* — because the tier that did not is the one flying blind.
116131

117132
## What does that hide?
118133

119134
Bugs that exist only while the stream is open and fix themselves before it closes.
120135

121136
We shipped one recently and fixed it, and where it lived is the whole argument: a cockpit capability, on the harness with no per-fixture opt-in.
122137

123-
A demo runs a child graph as a plain node — the shape [the subgraphs post](/blog/langgraph-subgraphs-when-to-split) ends on. A plain subgraph node's events aren't tagged as a delegated subagent, so the bridge merges the child's tokens into the transcript as they arrive.
138+
A demo runs a child graph as a plain node — the shape [the subgraphs post](/blog/langgraph-subgraphs-when-to-split) ends on.
139+
A plain subgraph node's events are not tagged as a delegated subagent, so the bridge merges the child's tokens into the transcript as they arrive.
124140

125-
The child in that graph produces an internal research brief, meant for the parent to write its answer from. Without the option that whitelists which nodes count as transcript, that brief renders as its own chat bubble, and the message list transiently reaches three.
141+
The child in that graph produces an internal research brief, meant for the parent to write its answer from.
142+
Without the option that whitelists which nodes count as transcript, that brief renders as its own chat bubble, and the message list transiently reaches three.
126143

127-
Then the run settles. The parent publishes its authoritative state, the transcript is rebuilt from it, and the extra bubble vanishes.
144+
Then the run settles.
145+
The parent publishes its authoritative state, the transcript is rebuilt from it, and the extra bubble vanishes.
128146

129147
Read that sequence again from a test's point of view.
130148
The end state is correct. Two messages, in the right order.
131149
Assert on the finished DOM and it passes — not by luck.
132150

133-
Confirming the fix meant driving it against a live model and sampling the DOM on a tight interval for the length of a full run, watching that the message count never crossed two. Not something the suite does, and not something it could tell us.
151+
Confirming the fix meant driving it against a live model and sampling the DOM on a tight interval for the length of a full run, watching that the message count never crossed two.
152+
Not something the suite does, and not something it could tell us.
134153

135-
Let's generalize, because this isn't specific to us.
136-
Any assertion that runs after an `await` sees a settled system. A self-correcting bug is precisely one that settles.
137-
So the class of defects a final-state suite cannot see isn't random — it's exactly the ones that repair themselves.
154+
That generalizes, because this is not specific to us.
155+
Any assertion that runs after an `await` sees a settled system.
156+
A self-correcting bug is precisely one that settles.
157+
So the class of defects a final-state suite cannot see is not random — it is exactly the ones that repair themselves.
138158

139159
## What runs alongside it?
140160

141-
A live pass for what replay structurally can't see, and a weekly drift run for the model itself.
161+
A live pass for what replay structurally cannot see, and a weekly drift run for the model itself.
142162

143-
The live pass is unglamorous: for anything whose failure mode is mid-stream, drive it against a real model in a real browser before it ships. That's how the bubble above got caught. There's no clever tooling in it — the point is just that the deterministic suite was never going to be the thing that found it.
163+
The live pass is unglamorous: for anything whose failure mode is mid-stream, drive it against a real model in a real browser before it ships.
164+
That is how the bubble above got caught.
165+
There is no clever tooling in it — the point is just that the deterministic suite was never going to be the thing that found it.
144166

145-
Drift is the other half, and it got built properly on the way to this post. Our first design re-recorded fixtures and compared byte size — which detects that a response changed *size* while saying nothing about whether it changed *meaning*. The same trade again, one layer up. A model that starts returning something equally long and completely different sails straight through a size check.
167+
Drift is the other half, and it got built properly on the way to this post.
168+
Our first design re-recorded fixtures and compared byte size — which detects that a response changed *size* while saying nothing about whether it changed *meaning*.
169+
The same trade again, one layer up.
170+
A model that starts returning something equally long and completely different sails straight through a size check.
146171

147172
So we threw the metric away and kept the thing we already trusted.
148173

149174
The assertions are the drift check.
150175

151-
The drift run takes a tagged subset of the same e2e suite — contract assertions only: a reply renders, the research dispatch surfaces a subagent card, the interrupt panel appears — and points it at the live provider through the mock's record-proxy. No fixtures judged, no thresholds invented.
152-
If today's model stops calling the research tool, or the graph's prompts stop eliciting the interrupt, a spec we already believe in goes red and a weekly job opens an issue. Meaning drift is caught by construction.
176+
The drift run takes a tagged subset of the same e2e suite — contract assertions only: a reply renders, the research dispatch surfaces a subagent card, the interrupt panel appears — and points it at the live provider through the mock's record-proxy.
177+
No fixtures judged, no thresholds invented.
178+
If today's model stops calling the research tool, or the graph's prompts stop eliciting the interrupt, a spec we already believe in goes red and a weekly job opens an issue.
179+
Meaning drift is caught by construction.
153180

154-
One rule makes the subset work: a tagged assertion may depend on structure or on the prompt's own terms — an element exists, a reply to "say hi" matches `/hi/i` — never on the content of a canned response. A spec that expects the fixture's exact words fails against a live model whether or not anything drifted, so it stays in replay where it belongs.
181+
One rule makes the subset work: a tagged assertion may depend on structure or on the prompt's own terms — an element exists, a reply to "say hi" matches `/hi/i` — never on the content of a canned response.
182+
A spec that expects the fixture's exact words fails against a live model whether or not anything drifted, so it stays in replay where it belongs.
155183

156-
The first run flagged drift that wasn't there.
157-
The diagnostic differ reported that both tool-calling responses had "drifted" to plain text — while the specs proving those tools fired were green. The recorder had saved empty content because it couldn't parse tool-call deltas out of the stream, warning "fixture may be incomplete." The check's first finding was a blind spot in its own instrument; it now reports that case in its own category instead of as drift.
158-
It's run clean against the live model since.
184+
The first run flagged drift that was not there.
185+
The diagnostic differ reported that both tool-calling responses had "drifted" to plain text — while the specs proving those tools fired were green.
186+
The recorder had saved empty content because it could not parse tool-call deltas out of the stream, warning "fixture may be incomplete."
187+
The check's first finding was a blind spot in its own instrument; it now reports that case in its own category instead of as drift.
188+
It has run clean against the live model since.
159189

160190
## Conclusion
161191

162-
Push your seam as far out as you can afford. The further out it goes, the more of your stack is under test rather than simulated, and provider base URL is far out for how little it costs.
192+
My recommendation is simple: push your seam as far out as you can afford.
193+
The further out it goes, the more of your stack is under test rather than simulated, and provider base URL is far out for how little it costs.
163194

164-
Then write down which dimension you deleted to make it deterministic, in the file where you deleted it. Not in a wiki. Six months later that comment is the difference between "the suite is green" and "the suite is green, and here is what green does not cover."
195+
Then write down which dimension you deleted to make it deterministic, in the file where you deleted it.
196+
Not in a wiki.
197+
Six months later that comment is the difference between "the suite is green" and "the suite is green, and here is what green does not cover."
165198

166199
Ours was written down, which is the only reason it could be checked at all.
167200
Checking it surfaced two things: the deletion was a default two of our apps had already bought back, and our first drift design was measuring the wrong thing.
168201

169202
For us the list is short and specific: anything whose failure mode is mid-stream on a capability that never opted back into real chunking, and the model itself moving under the fixtures.
170203
Both are answered by pointing what you already trust at the real thing — the tagged specs at the live provider on a schedule, a live browser at anything mid-stream before it ships.
171-
Widening the drift net is tagging more specs, plus one chore: porting record mode to the harness the other thirty-two apps share. Widening the stream check is opting more fixtures into real chunking. Neither is designing anything new.
204+
Widening the drift net is tagging more specs, plus one chore: porting record mode to the harness the other thirty-two apps share.
205+
Widening the stream check is opting more fixtures into real chunking.
206+
Neither is designing anything new.
172207

173208
The [testing guide](/docs/langgraph/guides/testing) has the in-process tier, and [the subgraphs post](/blog/langgraph-subgraphs-when-to-split) has the bug that started this.
174209

175-
If your agent suite is green today, I'd like to know what you think it can't see.
210+
If your agent suite is green today, I would like to know what you think it cannot see.

0 commit comments

Comments
 (0)