Session Reconnect

When the engine socket drops mid-conversation, the app opens a new one on the same session id and tells the agent the conversation was resumed. This page is the client half of that; the engine half is summarized in Engine-side status.

The mechanism: reconnect is a session id

There is no reconnect protocol. Continuity is entirely derived from the session id:

  • The id is passed as the sessionId query parameter on connect. The engine reuses the session document behind it instead of creating one, which brings back the same turns, agent, attributes, tenant and user.

  • Chat memory is keyed by session id, so the model’s history comes back with it (capped at 50 messages server-side).

  • The realtime system prompt switches to its .reconnect variant when that history is non-empty, so the agent resumes mid-conversation instead of starting cold.

  • #resume is the reconnect counterpart of #intro — the engine maps it to "the conversation has been resumed after a network disconnection".

The client sequence is therefore:

socket dropped unexpectedly
→ connect again with sessionId=<the same id>
→ wait for #ready               (never send anything before it)
→ send #resume                  (instead of #intro)
→ carry on

#resume must go out as a text token, which is what EngineAPI.SendSessionResumeRequest sends. A typed action element ({"type":"action","name":"resume"}) is passed through the engine’s ActionMapper untouched and never becomes the resume instruction.

The engine does not replay history on a resumed session — it sends #ready and nothing else. The transcript the user sees survives because ConversationManager.Entries is never cleared on a drop; only CreateConversationAsync clears it. Repainting a transcript the client does not have would need GET /api/session/{sessionId}/turn, which nothing does today.

deviceRef matters here too: it defaults to the session id server-side, so a client that did not send a stable one would change device identity on every reconnect. DeviceRef.Get() derives it from the OS device identity, so this holds even across a reinstall - see Device Ref.

The client flow

ConversationManager.OnDisconnected is the fork. An intentional end (the app called EndConversationAsync, which sets _isEndingIntentionally) finishes the conversation as before. Everything else starts ResumeConversationAsync instead of publishing ConversationLost:

Step Behaviour

Report

ConversationInterrupted is published immediately, before the first attempt. ConversationResumed or ConversationLost always follows it.

Retry

EngineAPI.CreateSessionAsync on PipelineConfiguration.ForResume(sessionId), a second apart while the window lasts. The configuration is the one the conversation was created with — a copy of it, because PipelineConfiguration is a class and the original has to survive the resume unchanged.

Give up

When the next attempt would start past ReconnectWindowSeconds (30 s), or when the engine answered and refused — an EngineSessionException carrying an ErrorEngineElement, which the next attempt would get again.

Land

#resume goes out, the microphone starts again, SessionInfoChanged carries the new id, ConversationResumed is published.

What survives a resume is what makes it a resume rather than a restart: the chat entries, the user’s mute state, the conversation mode, the agent name. What is reset is what belonged to the connection: the turn id, the turn mute, the typing indicator, and any tool call still waiting for its result — no result is coming on a socket that is gone.

The 30 s window is the engine’s, not an arbitrary number: markAbandonedSessions runs every 10 s and flips a DISCONNECTED session untouched for 30 s to ABANDONED, firing post-session webhooks and end-of-conversation evaluations. Reconnecting later still technically works — the session document and chat memory persist — but the conversation has already been wrapped up behind the app’s back, so the app stops trying at that point. Note also that session aggregation and insight extraction run on every socket close, including a blip that is resumed a second later; a resumed conversation will run them again when it really ends.

Connect timeouts are asymmetric for the same reason: a first connect gets 60 s, a resume gets 8 s (EngineAPI.ResumeConnectTimeout), because a resume has a window to fit several attempts into. Both bound the connect only — see What the Socket.IO library does not tell you. Nothing bounds the wait for #ready after that, on either path: bringing an agent up can take tens of seconds, and a socket that is already up reports its own drop.

What the user sees

AppOrchestrator puts a busy prompt up on ConversationInterruptedReconnectingPrompt, presented as ReconnectingPopupContent: a spinner, "Connection interrupted", "Reconnecting you to the conversation…", and one full-width destructive button. It comes down on ConversationResumed, on ConversationLost, and on any interaction-state transition, so no flow can leave it behind.

The button abandons the reconnect, and abandoning is an ordinary exit: ExitInteractionAsync runs, EndConversationAsync cancels the resume on the way out, and no lost-conversation report follows — the user made that call themselves, so telling them the conversation ended would be reporting their own decision back at them. The report is reserved for the reconnect running out on its own.

The notice is only shown while the interaction is Active. The reconnect itself runs regardless; there is just nobody to show it to otherwise.

The popup mutes the microphone through the ordinary PopupChanged path, and the resume starts capture before its own ConversationResumed reaches the orchestrator — so the mic stays muted for the popup’s 200 ms close transition and is unmuted by PopupChanged(false) when it lands. Nothing needs to sequence that; it is just why the order of those two publishes does not matter.

Abandoning a resume

A resume in flight is cancelled by EndConversationAsync and by CreateConversationAsync — the user left, or started something else, and the conversation being reconnected to is not wanted any more. Cancellation is also linked to the manager’s own destruction.

The awkward case is cancellation while a socket is already opening. CreateSessionAsync cannot be dropped mid-flight without leaving a live session behind, so the resume lets it finish and then ends it explicitly. Until it does, that session is a second session the app does not want, which is why EngineAPI.OnElementReceived and OnDisconnected both carry the Session they came from and ConversationManager ignores anything that is not _session. Without that filter a discarded session’s teardown would run the teardown of the live one.

Detecting the drop the socket will not report

ConversationManager.CheckNetworkPath polls Application.internetReachability twice a second while a session is live and drops the session through EngineAPI.DropSession — a close with no #end, so it takes the ordinary unintentional-disconnect path and resumes — as soon as the path is not the one the session was opened on. The path is captured by CaptureNetworkPath both when a conversation is created and when a resume lands, so each session is judged against its own.

It compares against the captured value rather than testing for NotReachable, because losing the path is not the same as losing connectivity. A socket opened over Wi-Fi is just as dead when the device falls back to cellular and stays online, and on iOS that is the more common shape of the failure.

This exists because the OS is the only party that knows. See A stalled socket is only noticed on Android, and websocket pings do not fix it for why the engine’s own timer is too slow to matter and why the socket itself says nothing: on iOS a socket whose interface disappeared stays open and silent, so with an 85 s detection latency against the engine’s 30 s abandon window, a dropped Wi-Fi connection is unrecoverable by the time anything else notices. Android errors the socket within a second on its own, and the poll simply races it there — either way the outcome is the same disconnect.

A path that flaps will now cost a reconnect rather than being ridden out. That is the intended trade: the socket did not survive the change, so riding it out was never really an option, and ReachabilityCheckIntervalSeconds is the knob if the noise turns out to matter.

What the Socket.IO library does not tell you

Two defaults in Best.SocketIO make a dropped connection invisible on device. Neither shows up in the editor, where the reconnect is not exercised against a real network failure. The first is fixed; the second currently has no client-side fix, and the section on it is here so the next person does not spend the afternoon rediscovering why.

A connection that never comes up is reported to a socket the app does not have

SocketManager.EmitError and IManager.EmitEvent deliver to Namespaces["/"] only. The app opens the /socket/io/pipeline namespace and never asks for the root one, so every manager-level failure — transport error, connection timeout, connect_error, connect_timeout — is dropped on the floor. The Disconnect event is no help either: Socket.Disconnect dispatches it only if (IsOpen), and a namespace that never connected is not open.

The consequence was that CreateSessionAsync had three ways to complete and a failed connect triggered none of them, so the task stayed pending forever: the resume loop never took its second attempt and never checked its own deadline, and the reconnecting popup had no exit.

watchConnectFailureAsync closes that hole. It polls until either the connect timeout elapses or the manager puts itself in States.Closed — with Reconnection off, a manager that closed itself has given up — and fails the session through the same failSession path everything else uses. Opening the root namespace to listen for the real events would be the tidier fix on paper, but it makes the client send a namespace-connect for / that the engine has no handler for, and the refusal that comes back is indistinguishable from a real failure.

The watchdog stops the moment the socket connects, and this is the part to keep hold of when tuning the timeouts. Everything it is there for happens while the socket is coming up; once it is up, #ready is the engine’s to answer whenever it is ready to, and a drop from that point on arrives as an ordinary Disconnect. A watchdog that kept counting into the #ready wait would fail sessions that were about to succeed.

A stalled socket is only noticed on Android, and websocket pings do not fix it

SocketOptions.WebsocketOptions is non-null out of the box with PingIntervalOverride set to TimeSpan.Zero, which WebSocketTransport reads as an explicit "no pings" and turns SendPings off. The only liveness check left is then Socket.IO’s own server-ping timer — PingInterval + PingTimeout, which the engine hands out as 25 s + 60 s, so 85 s. That is nearly three times the 30 s the engine then waits before abandoning the session, which is why a silent drop was unrecoverable no matter how well the client behaved.

That is fine on Android, where disabling Wi-Fi tears the socket down and surfaces a real TCP error at once, and useless on iOS, where the socket simply goes quiet. Detection there lands outside the engine’s 30 s window, so by the time the app starts reconnecting there is nothing left to reconnect to.

Turning websocket pings back on is the obvious fix and it does not work against this engine: with PingIntervalOverride at 3 s the connection was closed on a roughly ten-second cycle on both platforms, which is OverHTTP1’s ping-then-`CloseAfterNoMessage watchdog firing on a pong that never comes. Whether the engine ignores ping frames or closes on them was not established, only that the pong does not come back, and no value of CloseAfterNoMessage helps — one that does not false-positive would have to be longer than the server ping interval it is trying to beat. The setting is therefore left at the library’s default.

Nothing else inside the socket is a way around it: the engine.io ping is consumed by the transport before any namespace sees it, lastPingReceived is private to SocketManager, and during an idle conversation no elements arrive to time out on. Asking the OS instead is what the reachability poll above does, and it is the only thing that makes the common case — Wi-Fi switched off — detectable at all.

What the poll cannot see is a path that dies while still looking like the same path: an upstream break, a NAT table expiring, a captive portal starting to black-hole. Those still cost the full 85 s. Closing that gap needs the engine’s side: a lower pingTimeout, or a pingInterval short enough that the timer is not the long pole. TCP keepalive is the one remaining client-side lever — HTTPManager.PerHostSettings exposes it and Best.HTTP leaves it off by default, and unlike websocket ping frames the probes are answered by the peer’s kernel rather than the engine — but it only fires on an idle socket, and a conversation streaming microphone PCM is never idle, so it would cover the quiet moments only.

Those two values are the engine’s, sent in the engine.io handshake and adopted verbatim — protocol v4 has the server ping and the client pong, and no client-side override exists in the library. CreateSessionAsync logs them next to the ready line, so the figure above is whatever the deployment actually served rather than a default anyone assumed; the 60 s timeout in particular is three times the Socket.IO default, so somebody raised it deliberately. The Kotlin promethist-client SDKs sit behind the same timer on all four of their platforms and have no override either, so this is the engine’s number for every client, not a Unity problem.

That also makes the 30 s window narrower than it looks. It only starts once the drop is detected, so on a silent drop the engine must hold the session for the detection latency plus the client’s 30 s, not 30 s from the socket closing. If markAbandonedSessions is measuring from the close, a silent drop is unreconnectable by construction however well the client behaves.

Engine-side status

As of this writing the engine ignores sessionId on both socket transports. PipelineService.process overwrites the configuration with sessionId = newId() for every pipeline window, introduced for "new session id per new pipeline" and made unconditional when the flow was rewritten to the synthetic-#ready form. Only PUT/GET /api/pipeline/{key} still honours a client-supplied id.

Until that is fixed (the minimal fix on that side is to keep the incoming id for the first window only), a reconnect opens a fresh session: the socket comes back, #resume is delivered, and the agent answers it with no history behind it. The client logs this rather than guessing — EngineAPI warns Engine did not resume session '…' and started '…' instead whenever the id that comes back on #ready is not the one that was asked for. That warning disappearing is the signal that the engine half has landed.

The client is correct either way, which is why it was built first: nothing about the flow depends on the engine honouring the id, only the quality of what the agent says next does.

Traps

Never send #exit if you intend to resume. On the same socket, #exit ends the session and the next #ready deliberately starts a fresh session id. EndSession sends #end, which closes cleanly, and it is only sent for an end the app asked for.

The intentional/unintentional distinction is load-bearing twice over. It already decided whether ConversationLost was published (see App Flow, "Only publish the ends nobody asked for"); it now also decides whether a drop is resumed at all. An end the app asked for must never be resumed — it would reopen the conversation the exit flow is tearing down.

A resume is not a new conversation for the mode switch either. ConversationMode changes are deferred to the next conversation while one is running, and IsResuming counts as running — otherwise a mode change during a reconnect would be applied locally while the engine kept the input mode the socket was opened with.