I pressed Run on a tool-calling chat panel, watched the model stream {"query":"dev.to tag:accessibility into the preview, and then the whole panel blinked out of existence. My composer had a half-typed follow-up in it, the model was still billing tokens somewhere, and the only thing left on screen was a red box that said Unexpected end of JSON input. The user could not tell whether the search had run, whether it had been cancelled, or whether retrying would run it twice.
That last sentence is the actual bug, by the way. The exception was just the messenger. What follows is the retrospective: the state model I should have written first, a fixture you can replay with no API key, the two root causes, and the accessibility contract that keeps a half-finished tool call honest.
The state table I wish I had drawn first
Before touching the parser, write down every terminal shape a streamed tool call can land in. Mine was missing half of them, which is why the UI had no branch to render.
| What the stream does | Old UI | New UI |
|---|---|---|
| Arguments arrive in 40 fragments | throws on fragment 1 |
streaming → partial preview |
| Last fragment closes the JSON | never reached |
complete, tool may run |
Server stops with finish_reason: "length"
|
throws, panel unmounts |
truncated, retry offered, tool never runs |
| Connection drops mid-object | throws, draft lost |
invalid, buffer preserved verbatim |
| User cancels | panel unmounts |
cancelled, buffer kept for inspection |
Two of those rows are not errors at all, and treating them as errors is what cost me the draft. truncated is a budget decision made upstream. cancelled is a user decision made locally. Neither deserves an exception boundary.
Reproduce it without a key, a quota, or a network
I do not want my regression tests to depend on a provider being up, so I record one stream once and replay it as a fixture forever. Here is the smallest harness I use, in Node with no dependencies:
mkdir -p fixtures && node replay.mjs fixtures/truncated-toolcall.sse
// replay.mjs
import { readFileSync } from "node:fs";
function* events(path) {
const lines = readFileSync(path, "utf8").split("\n");
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
yield JSON.parse(payload);
}
}
for (const event of events(process.argv[2])) {
const choice = event.choices?.[0];
const args = choice?.delta?.tool_calls?.[0]?.function?.arguments;
if (args) console.log("chunk", JSON.stringify(args));
if (choice?.finish_reason) console.log("finish", choice.finish_reason);
}
The fixture itself is four lines of hand-written SSE that imitate the shape, not a recording of anyone's model. Yes, that is a constructed transcript, and I label it that way so nobody mistakes it for a benchmark.
data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"{\"query\":\"dev.to tag:acc"}}]}}]}
data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"essibility\",\"limit\":2"}}]}}]}
data: {"choices":[{"delta":{},"finish_reason":"length"}]}
data: [DONE]
Run that and the bug is obvious in about ten seconds: arguments is a string fragment stream, and I was calling JSON.parse on each fragment as if it were a document.
Root cause one: I parsed a delta like a document
The shipped version was three lines, and all three were wrong in the same way:
const args = event.choices?.[0]?.delta?.tool_calls?.[0]?.function?.arguments;
const parsed = JSON.parse(args); // 💥 on the very first fragment
render(parsed);
Look at the failure order. It did not break on the last brace; it broke on the first character of the first fragment, because {"query":"dev is not JSON. That is why the error boundary fired while the model was still producing the first key, and why the panel died before any output existed. A per-chunk parse is a category error, not an edge case.
The fix is a buffer plus a scanner that knows where it is inside the object. It tracks string state, escapes, and an open-bracket stack, then closes what is still open — bounded, because an unbounded repair is a fuzzer's dream.
export const MAX_DEPTH = 32;
export function scan(buffer) {
let inString = false, escaped = false;
const stack = [];
for (const ch of buffer) {
if (escaped) { escaped = false; continue; }
if (ch === "\\") { escaped = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === "{" || ch === "[") stack.push(ch);
else if (ch === "}" || ch === "]") {
const open = stack.pop();
if (!open || (ch === "}") !== (open === "{")) return { ok: false, reason: "unbalanced" };
}
}
return { ok: true, inString, stack };
}
export function repair(buffer) {
const s = scan(buffer);
if (!s.ok) return { status: "invalid", reason: s.reason };
if (!s.stack.length && !s.inString) return { status: "complete", text: buffer };
if (s.stack.length > MAX_DEPTH) return { status: "invalid", reason: "too-deep" };
let text = s.inString ? buffer + '"' : buffer;
for (let i = s.stack.length - 1; i >= 0; i--) text += s.stack[i] === "{" ? "}" : "]";
try { return { status: "partial", text, value: JSON.parse(text) }; }
catch { return { status: "invalid", reason: "repair-failed" }; }
}
That is an excerpt, not the whole reducer, but it carries the invariant that matters: repair never throws and never returns a value it did not successfully parse. Everything else is presentation.
Root cause two: finish_reason: "length" is not an exception
My second mistake was upstream of the parser. A truncated tool call is a known terminal state with a known cause, and I had no type for it, so it fell through to the error path. Here is the shape I use now, typed so the compiler nags me about missing branches:
type ToolCall =
| { status: "streaming"; buffer: string }
| { status: "partial"; buffer: string; preview: string }
| { status: "complete"; value: unknown }
| { status: "truncated"; buffer: string; preview: string | null }
| { status: "invalid"; buffer: string; reason: string }
| { status: "cancelled"; buffer: string };
When the terminal event says length, the reducer moves to truncated, keeps the buffer for inspection, and shows a retry that re-sends the same request under a new abort scope. The tool itself is never invoked with repaired arguments, and that distinction is the whole safety story: a repaired object is a display artifact, never an executable one.
The accessibility contract for half-finished output
A streaming preview is a live region that screams forty times and says nothing useful. I settled on this contract, and it survived screen-reader passes because it announces almost never:
- The partial preview sits in a region with
aria-live="off"andaria-busy="false"; it updates visually without interrupting speech. - One
role="status"node announces exactly once per terminal state —complete,truncated,invalid,cancelled. - The composer keeps focus through every transition, because nothing here replaces the subtree that owns the textarea.
- Retry and Cancel are real buttons in DOM order after the preview, reachable by Tab, and Cancel stays pointer-independent during streaming.
- Truncated and invalid states expose the raw buffer in a disclosure widget, since debugging output is a feature for the person hitting it.
Accessible names matter here too: "Retry" alone is ambiguous when two tool calls are on screen, so the label includes the tool name and the reason.
Where I reproduced it, and why that matters
I originally hit this while building against a cheap endpoint and could not afford to keep re-running a long tool prompt after every change. I moved the reproduction to MonkeyCode, an open-source coding agent project that at the time of writing advertises two things relevant to this loop: free model access and a free server option, with a free allowance of 10 million tokens listed on the project page — check the repo for the current terms, because allowances are exactly the kind of number that changes.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
What that gave me was not magic, it was iteration speed: I could replay the fixture against a real streamed endpoint dozens of times in one afternoon without rationing calls. The free server option mattered for a duller reason — I needed a process that stays up long enough to be killed mid-stream, and killing a local tunnel is a much worse test than killing a small deployed one.
A QA matrix, because one browser lies
| Transition | What I check | Tooling |
|---|---|---|
| streaming → partial (×40) | no announcement per chunk, no layout jump | NVDA + Firefox, VoiceOver + Safari |
| partial → complete | one status announcement, tool may run | both of the above |
| streaming → truncated | retry focused after announcement, buffer visible | keyboard only |
| streaming → cancel | buffer retained, no orphan fetch on reload | DevTools network throttle |
| invalid mid-object | error text names the reason, draft intact | screen reader + manual |
Run the keyboard rows with the mouse physically unplugged. I am serious; that is how I found that my Cancel button was inside the preview that gets replaced on each chunk.
Limitations, and who should skip this
Bounded repair produces plausible objects, and plausible is dangerous: a repaired {"limit":2 becomes {"limit":2} and looks authoritative. If your tool call writes to a database, charges a card, or sends an email, do not use this path for execution at all — validate the complete payload against a schema and fail closed.
Also skip the replay harness if your failures are timing-dependent rather than shape-dependent; a deterministic fixture will happily hide a race. And if you need hard guarantees about concurrency, do not read a free-tier endpoint as a load-testing rig — that is not what it is for, and I would not trust anyone's numbers about it, including mine.
The question I now ask before every stream
Which of these states has no branch in my UI? It takes two minutes to answer and it would have saved me an afternoon, a lost draft, and one very confused user who thought their search had silently run.
If you want to replay this fixture against a live stream without a paid key, the free model access on MonkeyCode is the least wasteful loop I have found so far. And if you hit a variant I missed — a provider that closes the object but never sends finish_reason — send me the browser, OS, assistive tech version, and the exact chunk where it broke, and I will add it to the matrix.
Top comments (0)