Feature/sse token streaming onebrain - #1429
Closed
yuyixg wants to merge 6 commits into
Closed
Conversation
Hoisting the listener call out of the OnIndicationReceived branch is what lets a listener registered for any other event run at all, and nothing was holding that in place. A theory over three event names covers it, plus a case showing a listener is not called for an event it did not register for. Restoring the old shape fails exactly the two non-indication cases, which is the behaviour the SSE token stream relies on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both registered observers ran for every token. ConversationObserver reached the listener that writes the frame; ChatHubObserver answered the same event by building a DTO, serializing it, and awaiting a push to the SignalR group -- for a caller that is reading the response body and has no SignalR connection at all. With a Redis backplane configured, Clients.Group publishes whether or not the group has local members, so that was one Redis round trip per token, awaited synchronously on the completion loop's thread. The endpoint now names the observer it needs. SendMessage is untouched, and that is the endpoint BotSharp-UI posts to, so clients that do want events over SignalR are unaffected. The trade is that a conversation driven through /sse no longer feeds the SignalR hub, so watching the same conversation live in the UI while a caller drives it over SSE will show nothing. Picking one transport per conversation was already the assumption. Verified after the change: 82 delta frames still stream over 2.05s, 155 bytes each, and their concatenation still matches the closing frame's text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps a conforming SSE client trips over. A reply ended by closing the connection, which such a client reads as a dropped stream and answers by reconnecting; the endpoint ignores Last-Event-ID, so the reconnect resends the message and pays for a whole second reply. Verified against the endpoint: reconnecting with Last-Event-ID returned 200 and 12,749 bytes of a fresh answer. OnEventCompleted already existed for this, commented out at the call site, and now runs. The other is silence. An agent can take seconds to reach its first token -- measured 6.7s to the first indication and 8.4s to the first delta -- and a proxy watching an idle connection is free to drop it. A comment line every 20s keeps bytes moving without reaching the event parser. That heartbeat is the concurrent writer the earlier lock did not have: it ticks on its own schedule and can arrive while a frame is half written, so frames go out through one serialized writer again. Verified with the interval shortened to 2s: four comment lines interleaved through a reply, a spec parser still read 85 events with none malformed, and the last one was [DONE]. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
The closing frame carried the whole reply again. For a caller appending deltas that is the text twice, and it left the frame's real purpose -- rich content, states, the function name, the fields a delta cannot carry -- looking like an afterthought behind a duplicate. A message that streamed now closes with an empty text. One that never streamed keeps its own, because a reply answered from a function or a template has no deltas and this is its only frame. Verified against the endpoint: 76 deltas concatenating to 384 characters, a closing frame with text "", and the same through copilot-bff -- 69 deltas, 360 characters, closing text empty, no parse errors logged. The reply still appears in full inside rich_content.message.text on that frame, which is left alone: it is a structured payload consumers render from, and blanking a field inside it would mean reaching into a concrete rich message type. A caller that reads text sees no duplicate; one that renders rich_content replaces rather than appends, so it shows the same reply either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blanking the closing frame's text missed where callers actually read the reply from: rich_content.message.text carried it in full, so the duplicate the change was meant to remove was still there for anyone rendering the rich payload. Only a plain text payload is emptied, and into a copy rather than in place. The message this comes from is appended to the dialog history after the callback returns, so blanking it directly would drop the reply from the record -- verified by reading the dialogs back: the assistant turn still holds all 351 characters in both fields. Other rich types keep their payload, which carries structure no sequence of deltas can rebuild. Verified both ways: direct, 71 deltas concatenating to 351 characters with the closing frame empty in both places and recipient, messaging_type, editor and fill_postback intact; through copilot-bff, 33 deltas to 149 characters, content .message.text empty, nothing logged. Callers reading the reply from the closing frame now need the deltas instead. Only a request that asked to stream is affected, so a client that never sets the flag still receives the whole reply in one frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The heartbeat goes at the author's call. With it goes the lock it was the justification for -- and that justification did not survive a test: with the lock removed and the interval cut to 5ms, 497 comment lines raced 83 data frames and produced no malformed frame, no parse failure, and 83 clean events through a spec parser. A frame is built into one buffer and written in one call, so there is no half-written frame for another writer to land inside. It was the original two-write-per-frame shape that had a window, and that is gone. The set of streamed ids goes because the provider already records this: IsStreaming is set on the message it builds when a completion streamed, and left alone on the one it builds for a tool call. Verified across the three flows that discriminate the blanking -- a streamed reply (63 deltas, closing text and rich text both empty), one that never streamed (no deltas, closing text 166 characters), and a function-driven turn through RoleDialogModel.From where the indication frames keep their own text and only the closing frame is emptied. Same through copilot-bff both ways, nothing logged. [DONE] stays: ending a reply by closing the connection is what makes a conforming client reconnect and pay for a second one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.