The Stream Had Been Lying to Us for Months
The chat UI received its completion event, streamCompleted, and did what a completion event tells a UI to do. The
spinner stopped. The submit button came back. The credential panel fetched its data and drew it.
The data was the thin version: a bare record of the credential, none of the detail. After a browser refresh, the same panel came back rich. Same session, same credential. The only difference was when we had asked.
How both were true at once
The agent in that chat calls tools on the user’s behalf, and some of those tools need the user’s delegated access. The first time one does, the flow pauses for an OAuth consent popup. The user approves. The agent then carries on: the token exchange completes, the tool runs with the credential, the model writes its answer. All of that arrives over the same streaming connection.
The completion event fired when the OAuth popup closed. It did not fire when the stream ended.
Everything after consent (the tool call, the credential being used and stored, the model’s response) streamed in with no completion signal at all. The UI had already been told the turn was over. The credential panel had already fetched, at a moment when the credential did not yet exist in full, and nothing told it to fetch again.
The cost was not limited to one panel. The submit button and spinner reset at the moment of consent, while the agent was still working. A user could type and send a new message during active tool execution, and that message was lost.
The event had been saying done when it meant paused for auth since the day OAuth was first wired in.
Why nobody saw it
The answer is an architectural choice we still think is a good one. The UI is built on an event bus: each component subscribes to the individual events it cares about (a tool result, a message part, a credential record) and renders them as they arrive. Nothing in the chat waited on the stream’s lifecycle. Components took events until no more came.
So the broken completion signal had no consumer that could tell. The chat rendered the answer, because the answer arrived as events. The credential cards rendered, eventually, because their events arrived too. Each component healed around the gap without anyone designing it to.
In effect it was a broken fire alarm in a building that had never had a fire. The fire, when it came, was a credential panel that genuinely needed to know when the turn was finished, so it could fetch the complete record once. It was the first feature that depended on the completion signal being true, and so it was the first to notice it was not.
How it was found
Not by reading code. The code looked right; it subscribed to the completion event and fetched on it, which is what the event is for.
It was found by tagging every call to the credential fetch with the trigger that caused it, then running one live OAuth flow and reading the log. The completion event fired once, before consent, and fetched a session with no credentials in it yet. After the user approved, the log showed nothing from the credential component at all. There was no second completion event.
Thirty minutes of live tracing found it after hours of reading had not, because the defect was not in any line of code. It was in the order in which things happened.
What changed
The first attempt was the obvious one: listen for the OAuth-completed event and wait two seconds. It failed for a reason worth writing down. OAuth-completed also fires at popup close, before the token exchange. Adding a delay to the wrong signal produces the wrong signal, later.
The fix that held stopped asking the stream when it was finished and started watching for evidence that the work had happened. When a tool uses a credential, the stream carries a credential-usage record for it. That record can only exist after the token exchange completed and the tool ran with the credential. The UI now watches the stream for it and fetches the credential record when it appears. The refresh is keyed to the fact, not to a lifecycle event that claims to describe the fact.
The panel now fills in stages: empty before consent, a shell once consent is given, the full record once the tool has used it. Each stage is driven by an event that is true at the moment it fires.
The completion gap itself was written down that day as a known defect in the integration, with its consequences listed: premature button reset, lost messages, any feature that needs the true end of a turn. The credential panel no longer depends on it.
The class, not the incident
Every streaming system has a lifecycle signal (done, complete, finished, idle) and most components ignore it, because consuming events individually is more robust. That robustness is exactly what lets the lifecycle signal rot. When nothing reads a signal that matters, nothing tests it, and the first feature that does read it inherits every regression since the signal was last true.
The tell is a difference between two reads of the same state: the live view and the refreshed view disagree. That gap is the lifecycle signal misreporting when it is safe to read.
The line that stays
A completion signal that no component depends on is one that no component has ever tested. When one finally does depend on it, prefer evidence that the work happened to an event that says it is over.
It said “done” when it meant “paused for auth.”