How Three Agents Diagnosed and One Agent Fixed Fifteen Rendering Bugs
Parallel diagnosis, serial implementation, and a full-corpus check that proved nothing else broke
Fifteen pages came out of our converter looking wrong. Three agents went looking for the reasons, one agent applied the fixes, and a full test run over 28,932 pages checked that nothing else broke. This write-up is about the method: how the work was split, how each agent was kept honest, and how the results were verified.
| Outcome | |
|---|---|
| Target pages passing | 12 / 15 |
| Corpus passing | 27,142, up from 27,087 |
| Pages fixed across the corpus | 70 |
| Real regressions | 0 |
The setup
The converter turns a rendered web page into a single drawing that looks identical. It never does its own layout. The browser decides where everything goes, and the converter only reads those answers and draws shapes. That gave the agents one guiding question for every bug: which value did the browser already work out that we never asked for?
Fidelity is measured against a corpus of 28,932 test pages. For each page, the harness takes a screenshot of the browser, converts the page, renders the result in the same browser, and counts the pixels that differ. After a batch of changes, fifteen pages in one folder of the failure report were broken. Their diffs ranged from 39 pixels to 70,115.
The task and its rules
The request was one line: fix these. The constraints are what made it an agent-design problem.
- No regressions anywhere. A fix is done only when a full-corpus run, compared against a baseline taken before any change, shows nothing newly failing.
- Documented behaviour needs sign-off. Every source file has a companion document describing its model. A change to what that document says stops for human approval before it is written.
- Never swap code under a running suite. Rebuilding the module mid-run had crashed runs before.
- Heavy writes go to a fast scratch disk, not the main drive.
- Measure, don't recall. Every claim about browser behaviour has to come from the browser in this session, with the version and date written down.
The method: diagnose in parallel, implement in series
Diagnosis is slow and safe to fan out. Implementation shares one checkout and one build, so it cannot be. The lead session split the fifteen pages into three clusters and used two kinds of agent.
flowchart TB
L(["Lead session<br/>triage, approvals, baseline"])
L --> A1["Diagnosis agent 1<br/>6 pages"]
L --> A2["Diagnosis agent 2<br/>6 pages"]
L --> A3["Diagnosis agent 3<br/>3 pages"]
A1 --> R1[/"report + draft patches<br/>written to files"/]
A2 --> R2[/"report + draft patches<br/>written to files"/]
A3 --> R3[/"report + draft patch<br/>written to files"/]
R1 --> U{"Human approves<br/>documented-behaviour changes"}
R2 --> U
R3 --> U
U --> B[("Full baseline run<br/>on the scratch disk")]
B --> F["One fix agent<br/>owns the checkout<br/>three batches, one at a time"]
F --> C["One commit per cause"]Three read-only agents in parallel, then one writing agent in series. Nothing touched the checkout until the plans were approved.
Step one: read-only diagnosis
Each diagnosis agent got its pages and a hard boundary: no edits, no builds, nothing written to the real report. A full suite was running in the same checkout, so the lead waited until its output had been quiet for four minutes before loosening even that.
Each agent opened a failing page and its converted copy side by side and asked the browser what it had actually resolved: computed styles, measured box positions, and pixel colours read directly from the reference screenshot. Where a behaviour needed isolating, the agents built small probe pages. One agent, for example, gave a legend asymmetric margins to prove that the browser measures from the legend's border box, not its margin box. Guessing was never an option; every conclusion had an experiment behind it.
What they found
Fifteen pages collapsed into a handful of causes:
- A painted box that differs from the layout box. When a legend is taller than the border it sits on, the browser paints the surrounding box inset by a computed amount. The converter had been using the unadjusted box.
- A gap cut on the wrong side. The gap for a legend was hard-coded to the top edge, but in vertical writing modes the browser cuts the left or right edge.
- A gap that doesn't move with its element. A shifted legend moves, but the gap stays where layout originally put it.
- A theme border that shouldn't be drawn. The converter drew a native control's default border whenever the control looked native. The browser drops it as soon as the author sets any border or background.
- Hidden internal structure that was never captured. Placeholder text, button labels and expandable-section backgrounds live in parts of the page the capture never visited. The converter had been laying that text out itself, the one place it broke its own rule. The agents extended the capture to read those parts, and the hand-rolled layout code was deleted.
A detour that paid for itself
Agent chat replies were cut off mid-report twice. After that, every report went to a file and the chat reply carried only the path. When the session later restarted and every agent was gone, the reports, the draft patches and the baseline were all still on disk, and the work resumed from there.
Step two: a plan the human could approve in one reading
Almost every cause changed a sentence in some companion document, so the lead presented them together: what each document says now, what it would say, and why. One fix that changed no documented behaviour was tried immediately. It made its page worse, from 39 pixels to 75, and was reverted within the hour.
The human approved the plans and chose to batch them. A full run takes about twenty minutes, so one run per cause would have cost six hours. One run per cluster, with each cause still committed separately, cost about two.
Step three: one agent, one checkout, three batches
The baseline came first: the full corpus, every page recorded, before any fix touched the build. Then a single fix agent ran the same loop for each batch.
flowchart TB
a["Apply the batch's patches"] --> b["Format, test, lint"]
b --> c["Build"]
c --> d["Render the batch's own pages"]
d --> e["Full corpus run"]
e --> f["Diff against the fixed baseline"]
f --> g{"Any regressions?"}
g -->|"no"| cov["Coverage gates"]
g -->|"yes"| h{"Can the change<br/>even reach that page?"}
h -->|"no"| n["Render twice at the same code.<br/>Flips on its own = noise"]
h -->|"yes"| r["Fix the cause,<br/>or withdraw it"]
r --> b
n --> g
cov --> k["One commit per cause"]The regression loop. The comparison is always against the same baseline file, never against the previous round, so a regression left over from round one can't hide as "unchanged" in round two.
Every batch came back with a non-empty regression list: 21, then 23, then 17 pages. None was waved through. For each one the agent asked whether the batch could even reach it. Did the page contain anything the change touches? None did. Then it rendered each page twice at identical code. Most flipped between the two renders on their own: animation-driven pages alternating between 0 and 20,000 pixels, or a page whose script rewrites itself between the screenshot and the capture. The few that held steady rendered identically without the batch at all.
The fix that was withdrawn
One proposed change let a propagated underline reach a button's text. The full run showed a real regression: a family of pages jumped from 4,728 to 14,348 differing pixels each, because the drawing format paints underlines in the text's own colour and thickness while the browser uses the decorating element's. The correct fix means drawing underlines as separate shapes, which is a new piece of work, not a patch. So the diff was saved aside and taken out of the batch before commit.
Two blockers that had nothing to do with rendering
The automated permission check refused the fix agent's build commands three times, on the grounds that they modified shared resources. It also refused the lead's own attempt to add an allow rule, and refused a replacement agent while no rule existed. Each time the response was the same: stop, and put the exact rule in front of the human. Never route around the check through another agent. Once the human added the rule, the work ran unattended.
Result: twelve of fifteen, and seventy across the corpus
| Page | Before | After | Cause |
|---|---|---|---|
| legend-tall | 70,115 | 0 | painted-box inset |
| fieldset-overflow | 36,604 | 4,096 (pass) | inset + scroll area |
| legend-position-relative | 20,000 | 0 | gap position |
| legend-auto-margins | 13,429 | 135 (pass) | painted-box inset |
| details-pseudo-elements-002 | 13,776 | 23 | hidden structure |
| details-pseudo-elements-001 | 13,772 | 19 | hidden structure |
| field-sizing-placeholder-stretch | 12,360 | 0 | hidden structure |
| anonymous-button-content-box | 12,046 | 0 | hidden structure + border |
| fieldset-vertical | 11,006 | 1,214 (pass) | gap side |
| field-sizing-textarea-relayout | 47 | 0 | hidden structure |
| fieldset-painting-order | 10,098 | 10,098 (open) | paint phase, deferred |
| propagate-text-decoration | 182 | 105 (open) | withdrawn |
| iframe-scrolling-attribute-values | 39 | 39 (open) | one glyph, sub-pixel |
Two video pages at 42,278 pixels each went to 0 through the capture step rather than the converter, so they are left out of the table.
The last full run passed 27,142 pages against the baseline's 27,087. The comparison script counted 70 pages fixed, more than four times the fifteen that were asked for. Most were pages nobody had named: transformed inputs, text alignment in buttons, placeholder alignment, textarea wrapping. Every regression entry was shown to be timing noise by rendering it twice, not by assuming it.
What is still open, on purpose
The float ordering needs a new paint phase that touches every page with overlapping floats, so it gets its own run. Underlines need drawing as shapes. And the last page differs by one glyph, a fraction of a pixel off, in text whose geometry is already exact. That is a candidate for the known-differences list, and that call belongs to a human.
What we would do again
Fan out the reading and serialise the writing. Three diagnosis agents cost nothing to run side by side, but two agents building the same module would have crashed the suite.
Compare against a fixed baseline, not "since last time". Checking every round against one file is what made a withdrawn fix and a noisy page distinguishable.
Treat noise as a claim that needs evidence. That means two renders at the same code, or one render without the change, never "it looks unrelated".
Make agents prove it with the source of truth. Every diagnosis came from asking the browser, not from remembering how it should behave.
Keep state on disk. Reports, patches, commit drafts and the baseline all lived in files, so a restart cost minutes rather than the day.
Treat permission boundaries as part of the design. When an agent is refused, the fix is a sentence to the human with the exact rule, not a cleverer route around it.