Skip to content

Feature/sse token streaming onebrain - #1429

Closed
yuyixg wants to merge 6 commits into
SciSharp:masterfrom
yuyixg:feature/sse-token-streaming-onebrain
Closed

Feature/sse token streaming onebrain#1429
yuyixg wants to merge 6 commits into
SciSharp:masterfrom
yuyixg:feature/sse-token-streaming-onebrain

Conversation

@yuyixg

@yuyixg yuyixg commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

No description provided.

marsyusms and others added 3 commits September 8, 2026 18:00
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>
@qodo-code-review

Copy link
Copy Markdown
Contributor

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

marsyusms and others added 3 commits September 9, 2026 13:51
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>
@yuyixg yuyixg closed this Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants