September 14, 2026

Your user closed the laptop. The answer kept going.

The first time someone reported this I did not believe it was a bug. A question, forty seconds of thinking, a train going into a tunnel, and the answer was gone — not delayed, gone, and they had been billed for it.

Thin amber linework on charcoal: a broken connection line reconnecting to a run that is still producing frames.

The instinct is to treat the HTTP connection as the thing that owns the work. It is right up until the work takes longer than a connection reliably lasts, which for an agent turn is roughly always. A retrieval, four tool calls and a reasoning model thinking for ninety seconds is a normal turn. A laptop lid, a tab suspend, a mobile handoff between wifi and LTE, a corporate proxy with an idle timeout — all normal too.

So the answer is to stop coupling them. Generation runs independently of your connection. Every frame carries an id, the response carries X-Run-Id, and a reconnect re-attaches to the same run and replays what was missed. A closed lid mid-answer does not lose the answer and does not pay for it twice.

That is one paragraph and about six months of edge cases.

The edge cases, since they are the interesting part

A reasoning model looks exactly like a dead socket. It can go minutes between frames. Anything with an idle read timeout — your proxy, your load balancer, a phone's radio — cannot tell that apart from a connection that died. So the server sends a comment frame (: keepalive) every 15 seconds while a turn is thinking rather than talking. The SDK drops them; if you are writing a client in another language, drop them too, and do not treat them as events.

A reconnect after the turn finished is not an error. Frames stay replayable for 10 minutes after the run ends, so someone reconnecting a moment late still gets the whole turn. Eviction is lazy, so a quiet deployment often keeps them longer — treat 10 minutes as the guarantee rather than the limit.

A reconnect to a run whose frames are gone answers 200, not 404. One terminal error frame, and the frame names the conversation. That distinction cost us a bug: on a first turn the conversation is created inside the turn, so the stream is the only place its id has ever appeared. A 404 on the reconnect left a finished answer sitting in a conversation the client could not name — the answer existed, was paid for, and was unreachable. The session id is now on the handle from the start frame onward, before the first token.

ts
const handle = ai.chat.stream({ message }, { onToken: render });
handle.sessionId();   // known from the first frame

The failure codes are four, and they need four different responses:

codewhat happenedwhat to do
stream_expiredthe turn finished, frames aged outread sessions.messages(sessionId) — the answer is there
stream_lostthe process generating it restartedsend the turn again
stream_cancelled / stream_failedcancelled, or failede.message has the server's words
stream_elsewhererunning, but not on the worker that answeredretry; do not resend

Retrying the POST blindly is the one genuinely wrong move. On stream_expired it pays for an answer that already exists.

The reload, which is a different problem

Everything above recovers a dropped socket, and the handle does it by itself. A page reload is not that. A new page has no handle, and the assistant's reply is not stored until the turn ends — so sessions.messages returns the question and an empty answer while generation is still going.

Ask the session what is running on it:

ts
const { run_id, status, authoritative } = await ai.chat.sessions.activeRun(sessionId);
if (run_id) ai.chat.attach(run_id, { onToken: (_d, full) => render(full) });

run_id is null when nothing is running, and status says what that null means: idle (the last turn ended normally), lost (a turn was generating and its process went away — nothing was persisted, send it again), elsewhere (running on another worker — retry), or running with an id.

lost used to be indistinguishable from idle. That is how a ten-minute answer became a blank bubble nobody thought to retry, on a conversation that looked perfectly healthy.

authoritative is the honest caveat: a run lives in the memory of the API process that started it, so a multi-worker deployment can only answer for the one that took your request. false means "no run here, possibly one elsewhere". True on a single-worker deployment, which is the common case.

You do not have to persist the run id for any of this, and that matters more than it sounds. A client that keeps it in memory loses it on reload. One that keeps it in storage still cannot help a user who opens the same conversation on their phone.

A run id is not a capability

Worth saying plainly because run ids travel — a 409 names the run in flight, resume errors quote one, and anything that logs them puts them somewhere else.

Attaching, steering and cancelling all check the same per-subject ACL that reading the transcript checks. A token that could not read the conversation cannot read its stream, cannot steer into the turn, and cannot stop it, whatever run id it holds. Steer and cancel are checked as writes — so a conversation another user may read is still not one they may interrupt.

A 404 on a reconnect means one of two things and deliberately does not distinguish them: nobody has a record of that id, or it belongs to a conversation this token may not read. Probing ids must reveal nothing.

What I would build differently

If you are writing this yourself: decouple the run from the request on day one, even before you need it. Retrofitting it means changing what a turn is, and every client you have shipped assumed the old answer.

And put the session id on the very first frame. It is one field and it is the difference between a recoverable failure and an answer nobody can find.

Read next