Files
puter/.github/workflows
Daniel Salazar 4881264c88 feat(ai): realtime reconnect notice, context restore and retry hardening (#4415)
* test(ai): end every realtime relay test's upstream before the next

The fake upstream now answers response.create with response.created first,
as OpenAI does, so closing those sessions no longer waits out the settle
window.

* feat(ai): ask realtime clients to reconnect before a restart

Open realtime sessions get a puter.reconnect event when the drain starts,
with close_in_ms set to the drain window, and new sessions are still refused
until the 1012 close at shutdown. A host extension can announce a restart
early by emitting serverRestartAnnounced; that only sends the notice, so a
false alarm leaves the server working normally. A session gets the notice at
most once a minute.

onServerDrainStart now receives the drain duration.

* fix(ai): retry realtime reconnects for about a minute and wait out offline browsers

Five attempts gave up after about 15 seconds, shorter than a server restart.
The conversation makes ten attempts with the backoff capped at 10 seconds,
and while the browser reports being offline it waits for the online event,
up to two minutes per reconnect, instead of spending attempts. close() ends
either wait and removes its listener.

* feat(ai): restore recent conversation context after a realtime reconnect

The provider starts a fresh session on every reconnect, so the conversation
lost all earlier items. The SDK now records finished items it can recreate
from the provider's own events: user text, user speech with a transcript,
assistant replies as text, and function calls with their outputs. After the
new session acknowledges its settings, and before ready fires, it adds back
the newest of them, up to 40 items and 48 KiB, which stays under the relay's
upload burst.

Assistant replies are cut to what the user saw or heard: the deltas shown
before an interruption, and the share of the transcript matching the audio
played before a truncation or a drop. Deleted items, unfinished replies,
images, speech without a transcript and incomplete function calls are left
out, as are function outputs whose call falls outside the window. Restored
copies take over their new item IDs. A refused item is reported once as
context_restore_failed without failing the reconnect.

* feat(ai): move a realtime conversation to a new session on the restart notice

On puter.reconnect the conversation waits until it is idle: no reply
requested, generating or playing, no user turn being spoken or transcribed,
no function call awaiting its output and no acknowledgment pending. Then it
reconnects through the usual path, emitting reconnecting (code 1012) and
ready with reconnected: true, without an error. Until then the session keeps
working, and if the server closes it first the normal 1012 reconnect takes
over. close() at any point stops the wait.

* docs(ai): realtime restart notice, context restore and reconnect limits

* test(ai): realtime reconnects against the local server

The mock provider answers with item events and whole replies like OpenAI.
New cases cover a dropped connection (cut through a TCP proxy so the client
sees 1006), a host-announced restart, and a draining server refusing the
reconnect; the first two check the provider receives the restored items
before the next turn. The browser media test waits for captured audio and
the reply instead of a fixed sleep. The live suite gains a tool and text
context round trip across a restart notice.

* ci: run the live realtime conversation tests with the AI provider suite

They need the OpenAI key, which only this workflow has, and skip themselves
without it.

* fix(ai): open the replacement before leaving a realtime session on the restart notice

The handover closed the current session and then reconnected, so every
notice cost a second or two in which sends failed and microphone audio was
dropped. On a single server that refuses new sessions while it drains, the
retries ran out during the drain and the conversation closed sooner than if
it had stayed. The conversation now opens a replacement session as soon as
the notice arrives and switches only once it is open and the conversation is
idle. A refused replacement is retried with backoff while the current
session keeps working; when the server closes it first, the held replacement
is used right away.

The idle check also missed some in-progress work:
- a reply created while the user was still speaking ended the user's turn;
  speech now lasts until its audio is committed or cleared, and a committed
  turn waits for the reply server turn detection creates
- only the newest function call was checked; every finished call since the
  last user message needs its output, and calls cut off by a cancellation
  don't count
- native session.update and conversation.item.create events the provider
  hadn't acknowledged could be lost; the switch waits for them, up to the
  acknowledgment timeout for items
- only the newest transcription was waited on; every committed item is
- clearing playback didn't recheck the switch

Restored items stay internal: the new session's copies of them no longer
reach listeners. At a reconnect, every queued item the user didn't hear is
cut, not just the one playing. A notice from a failed reconnect attempt is
dropped.

* test(ai): realtime replay after split settings, and a draining server refusing the replacement

The relay accepts the context replay right after two full-size settings
updates. Against the local server, a draining relay refuses the replacement
session while the current one keeps answering.

* ci: build the SDK in the live realtime step so it stands on its own

* fix(ai): a failed realtime reconnect attempt no longer poisons the next

An attempt that dropped after sending its session.update but before the
acknowledgment left that event ID queued, so the next attempt's
acknowledgment was matched to it and timed out, and so on until the
conversation closed. Turn state is now reset after each failed attempt.

* fix(ai): bound the realtime handover's waits and stop retries that can't succeed

- The first replacement opens within a randomized 3 seconds of the notice,
  so sessions notified together don't all open at once.
- A replacement refused with a code retrying can't fix, such as
  insufficient_funds, ends the attempts; the server's close still falls back
  to the usual reconnect.
- A held replacement that gets an error or its own server's notice is closed
  and replaced.
- A held replacement used after a drop gets a 3 second acknowledgment
  timeout, since its socket may have died with the network.
- close() during the switch closes the held replacement too.
- The reply to a committed turn and each transcription hold the switch for
  at most 10 and 30 seconds, like app items already did, and an app item is
  released only by an added item of its type, role and call ID.

* docs(ai): the realtime restart notice isn't guaranteed before every restart
2026-10-30 18:06:54 -07:00
..
2026-10-21 13:04:23 -07:00