Read first: Asynchronous Calls · CQRS — Queries vs. Content Access · Data Binding · Per-Hub TaskScheduler

The one-sentence architecture

The writer, the layout area, and the Blazor view all touch the same per-message workspace stream. There is no intermediary, no republishing, no data section transform — just a single stream that the AI execution loop pushes deltas into and the view subscribes to directly.

Quick reference

Side Responsibility Primitive
Writer — AI execution on _Exec Push every delta via .Update(...) through the thread hub's shared per-path handle. parentHub.GetMeshNodeStream(responsePath).Update(node => node with { Content = ... }) — backed by the process-wide IMeshNodeStreamCache
Layout area — backend Return a path-bound bubble control. No data-section transform, no subscribe-and-republish. new ThreadMessageBubbleControl { NodePath = $"{threadPath}/{messageId}" }
Blazor view — renderer Subscribe in BindData() via AddBinding. Re-render on every emission. Hub.GetMeshNodeStream(ViewModel.NodePath) — the same cache handle the writer pushes through

Data flow

_Exec Hub (AI execution) responseStream.Update() Owning Per-Node Hub validates · persists broadcasts patches Blazor View GetMeshNodeStream re-renders on emit RFC 7396 patch sync broadcast layout area returns NodePath-bound control only

Single source of truth: writer pushes deltas, owning hub serialises and broadcasts, Blazor view re-renders — no republishing.

_Exec hub ──responseStream.Update(...)──► owning per-node hub
                                              │
                                              ├─ validates
                                              ├─ persists
                                              └─ broadcasts via synchronization protocol
                                                    │
                                                    └─ subscribers (incl. the Blazor view) re-render

Per-chunk pushes are one-way and fire-and-forget — the writer's action block is never blocked waiting for a return path. Only the terminal status write is composed into the round's completion gate (the IObservable<Unit> the submission watcher subscribes to). Tool-call hub round-trips are orthogonal — they run on entirely different schedulers.

Writer side: route every push through the shared stream handle

// ExecuteMessageAsync is a direct method call (not a hub handler). The
// submission watcher invokes it after draining PendingUserMessages via
// stream.Update, and SUBSCRIBES to the returned observable: it completes
// when the terminal Status write has landed — that completion is how the
// watcher knows the round is over.
internal static IObservable<Unit> ExecuteMessageAsync(
    IMessageHub hub, RoundParams request, AccessContext? userAccessContext)
{
    var parentHub = hub.Configuration.ParentHub!;   // the thread hub — _Exec has no AddData
    var segment   = new ActiveResponseSegment(request.ResponseMessageId);
    parentHub.Set(segment);   // check_inbox reaches this to split cells mid-round

    // Every push routes through parentHub.GetMeshNodeStream — the process-wide
    // IMeshNodeStreamCache hands out ONE shared handle per path, the same handle
    // the GUI's ThreadMessageBubbleView reads from. (A per-run
    // workspace.GetRemoteStream would open a SECOND upstream handle whose writes
    // the GUI never sees — that was a real bug, since fixed.)
    IObservable<MeshNode> PushToResponseMessage(string text, /* tool calls, status, … */)
        => parentHub.GetMeshNodeStream($"{request.ThreadPath}/{segment.ResponseMsgId}")
            .Update(node =>
            {
                // Bad-data tolerance: never clobber a node whose content can't be read.
                var current = node.ContentAs<ThreadMessage>(parentHub.JsonSerializerOptions, logger);
                if (node?.Content is not null && current is null) return node;
                current ??= new ThreadMessage { Role = "assistant", Status = ThreadMessageStatus.Streaming, … };
                return node with { Content = current with { Text = …, ToolCalls = …, Status = … } };
            });

    // Streaming chunks: Subscribe(...) fire-and-forget — the writer's action
    // block never waits. Terminal status: the returned IObservable<MeshNode> is
    // COMPOSED into the round's completion gate, so ExecuteMessageAsync only
    // completes after the Completed/Cancelled/Error write has landed.
    return clientObs.Take(1).SelectMany(chatClient => /* streaming loop */ …);
}

Things worth noting about this shape:

Layout-area side: ship a path-bound template

public static IObservable<UiControl?> Overview(LayoutAreaHost host, RenderingContext _)
{
    var hubPath   = host.Hub.Address.ToString();
    var lastSlash = hubPath.LastIndexOf('/');
    var threadPath = lastSlash > 0 ? hubPath[..lastSlash] : hubPath;
    var messageId  = lastSlash > 0 ? hubPath[(lastSlash + 1)..] : hubPath;

    // Declare what to render and where to read its content from.
    return Observable.Return((UiControl?)new ThreadMessageBubbleControl
    {
        NodePath  = $"{threadPath}/{messageId}",
        ThreadPath = threadPath,
        MessageId  = messageId,
    });
}

The Overview method is a pure factory: given a thread-message hub path, return a control that points at it. No reactive chain, no data section, no host.UpdateData. The Blazor view does all the work of resolving the path to live content.

Blazor view: hold a stream, subscribe, render

public partial class ThreadMessageBubbleView : BlazorView<ThreadMessageBubbleControl, ThreadMessageBubbleView>
{
    // No view-local stream field: the shared IMeshNodeStreamCache entry IS the handle,
    // and AddBinding owns the subscription's lifetime.
    private string? Role        { get; set; }
    private string? AuthorName  { get; set; }
    private string? messageText { get; set; }
    private IReadOnlyList<ToolCallEntry>?   toolCalls    { get; set; }
    private IReadOnlyList<NodeChangeEntry>? updatedNodes { get; set; }

    protected override void BindData()
    {
        base.BindData();

        // Legacy fallback for callers that pass concrete Text/ToolCalls
        if (string.IsNullOrEmpty(ViewModel.NodePath))
        {
            DataBind(ViewModel.Text,         x => x.messageText, /* ... */);
            DataBind(ViewModel.ToolCalls,    x => x.toolCalls,   /* ... */);
            DataBind(ViewModel.UpdatedNodes, x => x.updatedNodes,/* ... */);
            return;
        }

        // Canonical path: subscribe to the shared per-path handle — the SAME
        // IMeshNodeStreamCache entry the writer pushes through. The patch the
        // writer ships arrives here and the view re-renders.
        var stream = Hub.GetMeshNodeStream(ViewModel.NodePath);

        AddBinding(stream
            .Where(node => node?.Content is ThreadMessage)
            .Select(node => (Node: node, Msg: (ThreadMessage)node.Content!))
            .DistinctUntilChanged(t => (t.Msg.Text, t.Msg.ToolCalls, t.Msg.UpdatedNodes))
            .Subscribe(t =>
            {
                Role         = t.Msg.Role;
                AuthorName   = t.Msg.AuthorName ?? t.Msg.AgentName ?? "Assistant";
                messageText  = t.Msg.Text;
                toolCalls    = t.Msg.ToolCalls;
                updatedNodes = t.Msg.UpdatedNodes;
                InvokeAsync(StateHasChanged);
            }));
    }
}

Key shape:

🚨 A round may only report Completed if it produced what Completed asserts

The streaming loop above ends in a terminal write, and that write is a CLAIM about the round. The rule is one sentence:

A round persists ThreadMessageStatus.Completed only when every dispatched tool call returned and the model wrote a closing answer. Anything else terminates in a state that NAMES what happened.

Why this needs stating. The round used to assert success by default rather than from evidence, in three places that all defaulted the same way — ToolCallEntry.Status defaults to Success, ToolCallEntry.IsSuccess defaults to true, and ThreadMessage.Status defaults to Completed. So absence of a failure signal was persisted as a positive success claim, and two production defects that look unrelated are the same bug seen from two sides:

Observed What the thread said What actually happened
#1689 five consecutive rounds Completed, text narrating "Confirmed — the body is now saved correctly with all nine bullets" toolCalls: [] — nothing was ever written to the calendar
#1689 (side note) ToolCallEntry with isSuccess: true, status: Success, result "CreateEvent failed: were unable to deserialize" the tool FAILED, four times
#1715 Completed, "is ready" notification, answer ending mid-word at `ClientE the closing model turn produced zero tokens for 8¼ minutes and the stream ended empty

In every row the terminal state is a lie about what happened. That is a correctness defect, not a reliability one — a user is told work happened that did not.

Where the rule lives. RoundOutcome.Classify(finalText, toolCalls, producedClosingText) — pure, deterministic, no hub and no clock, so it unit-tests without a mesh and cannot drift between the streaming path and whatever reads the persisted cell. It returns a RoundConclusion carrying the verdict, the tool-call log with unfinished calls re-stamped, and the ingredients for the diagnosis — a LocalizationKey plus the unfinished tool NAMES. 🌍 The classifier decides and names; the sentence is rendered by the caller from the round's own AccessContext.Locale, the same explicit-locale rule the other terminal-error branches follow (never ambient CultureInfo — a round hops schedulers):

Verdict Fires when Cell status
Answered every call returned and the model closed Completed
ToolCallUnfinished a call was dispatched and the stream ended before its result Error
NoOutput no text and no tool calls at all Error
NoFinalAnswer tools ran, then the closing turn produced nothing Error

Three details are load-bearing:

IsSuccess = false was unserializable until this was fixed, which is why #1689 saw isSuccess: true on the persisted node even where the round had computed false. The hub serializer runs DefaultIgnoreCondition = WhenWritingDefault, and false IS the CLR default for a bool, so an explicit false on a property whose initializer declares true is dropped on the wire and rebuilt as true by the reader. [JsonIgnore(Condition = Never)] on ToolCallEntry.IsSuccess is what lets the false survive the hop — the same declared-true-bool trap, and the same fix, as ImportDeleteRequest.Mirror. Any bool whose initializer is not the CLR default needs it.

What this deliberately does NOT do. It does not try to detect a model that simply lies in prose while calling no tools — a round that produced text and dispatched nothing is, from the harness's side, an ordinary answer, and there is no server-side signal to fail on. Nor does it add a liveness bound below MaxStreamingDuration for a zero-output stream; a silent provider is a provider fault, and a new timer would be a second mechanism papering over it rather than making the outcome honest. The invariant here is about what the round is allowed to CLAIM.

Anti-patterns

❌ Don't Why it's wrong ✅ Do instead
parentHub.Post(new UpdateThreadMessageContent { ... }, o => o.WithTarget(...)) per chunk Two-hop write per chunk; the receiving hub's action block activates 30+ times during a single streaming run responseStream.Update(node => node with { Content = ... }) on a long-lived stream
host.SubscribeToDataStream(dataKey, syncStream.Select(... => ThreadMessageViewModel.FromMessage(m))) Layout-area-as-republisher; content goes through three intermediaries before the view sees it new ThreadMessageBubbleControl { NodePath = ... } — the view subscribes directly
new ThreadMessageBubbleControl().WithText(new JsonPointerReference($"{dataPointer}/text")) Bind-by-value through a layout data section; freezes if the republish chain stalls new ThreadMessageBubbleControl { NodePath = path } — bind-by-path
workspace.GetRemoteStream<MeshNode, MeshNodeReference>(path, …) for a node by path Opens a second upstream handle whose writes the GUI's shared handle never sees — a real, since-fixed bug GetMeshNodeStream(path) — the one process-wide IMeshNodeStreamCache entry
await meshService.QueryAsync<MeshNode>($"path:{messagePath}").FirstOrDefaultAsync() before appending text Lagged catalog read + manual append + write back = lost-update race Closure-state text accumulator + GetMeshNodeStream(path).Update(...) ships the whole text

Cross-references

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.