Summary
During each streaming LLM run, StreamingPersistenceMiddleware.GetStreamingResponseAsync performs an unbounded IList<ChatResponseUpdate>.ToChatResponse() coalescing pass on every yielded update (line 64), and then does persistence-store I/O on the same call stack. In GUI hosts this call stack runs on the Avalonia dispatcher thread: the profiler stack shows
System.GC.AllocateUninitializedArray<char> (managed→native)
Microsoft.Extensions.AI ...MergeText / CoalesceContent / FinalizeResponse / ToChatResponse
Phantom.Workspaces.Llm.StreamingPersistenceMiddleware.GetStreamingResponseAsync line 64
Microsoft.Agents.AI.ChatClientAgent.RunCoreStreamingAsync
Phantom.Workspaces.Llm.AgentChat.RunProcessLoopAsync line 1770
Avalonia.Threading.SendOrPostCallbackDispatcherOperation.InvokeCore
Avalonia Dispatcher.MainLoop
The heavy per-update work (a fresh char[] allocation and full text concatenation of the entire accumulated assistant message, plus a persistence-store round-trip) blocks input, rendering and animation on the UI thread for the duration of every LLM stream. The MEAI coalescing and persistence I/O are pure compute / I/O — they must run on a worker thread. Only the view-model mutations that Avalonia binds to (running-item collections, chat history) legitimately need the foreground context.
Root Cause
There are two collaborating causes; both must be understood before fixing.
Cause 1 — the process loop is bound to the foreground (UI) scheduler by design
AgentChat.StartProcessingLoop explicitly schedules RunProcessLoopAsync on this.foregroundScheduler, which in the GUI is a SynchronizationContextTaskScheduler over the Avalonia UI SynchronizationContext:
features/Phantom.Workspaces.Llm.Core/AgentChat.cs:2233-2262
private void StartProcessingLoop()
{
...
// Run the process loop on the same foreground scheduler used for running-item mutations.
// In production this is the captured UI synchronization context ...
this.processTask = Task.Factory.StartNew(
() => this.acceptsUserInput
? this.RunProcessLoopAsync(this.cts.Token)
: this.RunHostedProcessLoopAsync(this.cts.Token),
this.cts.Token,
TaskCreationOptions.DenyChildAttach,
this.foregroundScheduler).Unwrap();
}
foregroundScheduler is captured in the constructor from the current SynchronizationContext when no explicit scheduler is supplied:
features/Phantom.Workspaces.Llm.Core/AgentChat.cs:139-142
this.foregroundScheduler = request.ForegroundScheduler
?? (SynchronizationContext.Current is not null
? TaskScheduler.FromCurrentSynchronizationContext()
: this.foregroundSchedulerPair.ExclusiveScheduler);
This binding is intentional: the loop mutates non-thread-safe, UI-observed collections (RunningItems, History, etc.) via CreateRunningItem / UpdateRunningItem / CompleteRunningItem, and those mutations must happen on the foreground context.
Cause 2 — the streaming enumeration runs synchronously on the loop's thread
RunProcessLoopAsync pumps the provider enumerator directly and does not offload it:
features/Phantom.Workspaces.Llm.Core/AgentChat.cs:1759-1785
providerEnumerator = this.StartRun(
chatMessagesToSubmit.ToArray(),
currentSession,
runCancellation.Token)
.GetAsyncEnumerator(runCancellation.Token);
while (true)
{
pendingMoveNext = providerEnumerator.MoveNextAsync().AsTask(); // line 1770
if (await WasCanceledBeforeCompletingAsync(pendingMoveNext, runCancellation.Token))
throw new OperationCanceledException(runCancellation.Token);
var hasNext = await pendingMoveNext; // no ConfigureAwait(false)
...
partialResponses.Notify(providerEnumerator.Current);
}
Each MoveNextAsync is invoked from the UI thread (because the loop resumes on the foreground scheduler), and any part of it that runs synchronously — or that resumes synchronously after an already-completed inner await — runs on the UI thread. The middleware body is:
features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs:48-78
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var buffer = new List<ChatResponseUpdate>();
var persistedCount = 0;
await foreach (var update in this.inner.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
{
buffer.Add(update);
var response = buffer.ToChatResponse(); // line 64 — heavy
var stableCount = update.FinishReason is not null
? response.Messages.Count
: Math.Max(0, response.Messages.Count - 1);
for (var i = persistedCount; i < stableCount; i++)
{
await this.PersistMessageAsync(response.Messages[i]).ConfigureAwait(false); // I/O
}
persistedCount = stableCount;
yield return update;
}
}
Note that ConfigureAwait(false) here only affects continuations inside the async iterator — it does not change which thread the consumer's MoveNextAsync runs on. When the inner client returns an already-buffered update (a fast local await, or the very first synchronous fast-path of a streaming SDK), execution stays on the caller's thread all the way through buffer.ToChatResponse() and into PersistMessageAsync. Because the caller resumed on the Avalonia dispatcher (Cause 1), the heavy MEAI coalescing (FinalizeResponse → CoalesceContent → MergeText → AllocateUninitializedArray<char>) and the persistence-store write execute on the UI thread — exactly the stack shown by the profiler.
Additionally, ToChatResponse() is called on every update, not just at end-of-stream. It re-coalesces the entire growing buffer each time, so per-update UI-thread cost grows with response length — a very large final response coalesces its full text on the UI thread on every intermediate update as well.
Affected Files
| File |
Role |
features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs |
Runs ToChatResponse coalescing + persistence I/O on the caller's thread; must offload to a worker. |
features/Phantom.Workspaces.Llm.Core/AgentChat.cs (lines ~1690–1830, and StartProcessingLoop at ~2233–2262) |
Schedules RunProcessLoopAsync on the foreground scheduler and pumps the provider enumerator directly on that thread. Fix must ensure the streaming enumeration + coalescing does not run on the foreground context, while UI-observable mutations (CreateRunningItem / UpdateRunningItem / CompleteRunningItem, partialResponses.Notify) are still marshalled back to it. |
features/Phantom.Workspaces.Llm.Core/PartialResponseConflator.cs (indirectly) |
Receives per-update notifications; already marshals VM mutations, so it is the natural boundary between "background streaming" and "foreground VM updates". |
Design / Fix
Guiding principle (per the owner's comment: "the StreamingPersistenceMiddleware needs to be mindful of foreground / background operations"): keep binding-affecting mutations on the foreground context; move pure compute and I/O off it.
Primary fix — offload the coalescing + persistence work inside StreamingPersistenceMiddleware
Wrap the stream-consumption body in a Task.Run so the heavy per-update work runs on the thread pool regardless of who called MoveNextAsync. The idiomatic shape for an IAsyncEnumerable-yielding middleware is a channel + background pump:
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var channel = Channel.CreateUnbounded<ChatResponseUpdate>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
// Run the enumeration, coalescing and persistence on the thread pool, never on the caller's
// (potentially UI) thread. The producer yields updates through the channel; the caller
// consumes them on whatever context it prefers.
var pump = Task.Run(async () =>
{
try
{
var buffer = new List<ChatResponseUpdate>();
var persistedCount = 0;
await foreach (var update in this.inner
.GetStreamingResponseAsync(messages, options, cancellationToken)
.ConfigureAwait(false))
{
buffer.Add(update);
var response = buffer.ToChatResponse(); // now on thread pool
var stableCount = update.FinishReason is not null
? response.Messages.Count
: Math.Max(0, response.Messages.Count - 1);
for (var i = persistedCount; i < stableCount; i++)
{
await this.PersistMessageAsync(response.Messages[i]).ConfigureAwait(false);
}
persistedCount = stableCount;
await channel.Writer.WriteAsync(update, cancellationToken).ConfigureAwait(false);
}
channel.Writer.Complete();
}
catch (Exception ex)
{
channel.Writer.TryComplete(ex);
}
}, cancellationToken);
await foreach (var update in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return update;
}
await pump.ConfigureAwait(false);
}
Ordering constraint preserved: the middleware still writes each stable message before the corresponding update is delivered to the consumer, because the pump only writes to the channel after PersistMessageAsync completes. The consumer still sees updates incrementally (not batched to end-of-stream); the coalescing/persistence cost is simply no longer on the UI thread.
Alternative — offload once at the process-loop level
If a wider fix is preferred, AgentChat.StartProcessingLoop can launch the enumeration portion of RunProcessLoopAsync on the thread pool while keeping the running-item mutations marshalled to the foreground scheduler (e.g. via RunOnForegroundAsync, already used elsewhere in this file — see comment at lines 388–406). The primary fix above is preferred because it is local to the middleware, cannot regress the existing foreground-context invariants (issues #909 / #1068), and directly addresses the owner's note.
What must remain on the foreground context
CreateRunningItem / UpdateRunningItem / CompleteRunningItem and any runningItem.Items mutation — Avalonia binds to these collections.
History.Add / AppendUserMessagesToHistory.
PartialResponseConflator.Notify (which internally marshals VM edits) — the loop still calls this from the foreground context; only the upstream production of ChatResponseUpdates moves to the pool.
Optional follow-up — avoid per-update full coalescing
buffer.ToChatResponse() is O(total text) per update. Once the heavy work is off the UI thread this is no longer a UX blocker, but a follow-up may want to compute the "message boundary" using role/finish-reason transitions on the buffer directly, calling ToChatResponse() only when a boundary or end-of-stream is observed. Out of scope for this fix.
Expected Tests
Add to features/Phantom.Workspaces.Llm.Core.Tests/StreamingPersistenceMiddlewareTests.cs:
| Test |
Scenario / expectation |
StreamingPersistenceMiddleware_CoalescingRunsOffCapturedSynchronizationContext |
Install a tracking SynchronizationContext on the calling thread, invoke GetStreamingResponseAsync from that thread, feed several ChatResponseUpdates through a fake inner client, and assert that every observed SynchronizationContext.Current sampled inside the ChatResponseExtensions.ToChatResponse path (via an AIContent/inner-client hook that records SynchronizationContext.Current at coalesce time) is either null or the default (thread-pool) context — never the tracking context. |
StreamingPersistenceMiddleware_PersistMessageAsyncRunsOffCapturedSynchronizationContext |
Same tracking-context harness; assert IAgentPersistenceStore.StoreAsync observes SynchronizationContext.Current off the tracking context on every stable-message write. |
StreamingPersistenceMiddleware_YieldOrdering_StableMessagePersistedBeforeNextUpdateYielded |
Existing invariant (already covered by TwoUpdates_FirstPersistedBeforeSecondYielded) must continue to hold after offloading — verify explicitly that when message N becomes stable, StoreAsync(N) completes before MoveNextAsync returns update N+1 to the consumer. |
StreamingPersistenceMiddleware_CancellationTokenPropagates_ToInnerEnumerationAndPump |
When the caller cancels, the offloaded pump observes cancellation and the channel completes with OperationCanceledException (or equivalent), matching the current behaviour. |
AgentChat_StreamingRun_ForegroundContextObservesOnlyVmMutations |
Integration-style: with a foreground SynchronizationContextTaskScheduler installed, run a scripted streaming response through AgentChat; assert that RunningItems/History observable mutations run on the foreground context (existing invariant), while a hook placed on the inner chat client records that ToChatResponse/StoreAsync did not run on it. |
Naming follows the Subject_Scenario_ExpectedOutcome PascalCase convention already used in StreamingPersistenceMiddlewareTests (e.g. SingleUpdate_FinishReason_PersistedOnStreamEnd, TwoUpdates_FirstPersistedBeforeSecondYielded).
Summary
During each streaming LLM run,
StreamingPersistenceMiddleware.GetStreamingResponseAsyncperforms an unboundedIList<ChatResponseUpdate>.ToChatResponse()coalescing pass on every yielded update (line 64), and then does persistence-store I/O on the same call stack. In GUI hosts this call stack runs on the Avalonia dispatcher thread: the profiler stack showsThe heavy per-update work (a fresh
char[]allocation and full text concatenation of the entire accumulated assistant message, plus a persistence-store round-trip) blocks input, rendering and animation on the UI thread for the duration of every LLM stream. The MEAI coalescing and persistence I/O are pure compute / I/O — they must run on a worker thread. Only the view-model mutations that Avalonia binds to (running-item collections, chat history) legitimately need the foreground context.Root Cause
There are two collaborating causes; both must be understood before fixing.
Cause 1 — the process loop is bound to the foreground (UI) scheduler by design
AgentChat.StartProcessingLoopexplicitly schedulesRunProcessLoopAsynconthis.foregroundScheduler, which in the GUI is aSynchronizationContextTaskSchedulerover the Avalonia UISynchronizationContext:features/Phantom.Workspaces.Llm.Core/AgentChat.cs:2233-2262foregroundScheduleris captured in the constructor from the currentSynchronizationContextwhen no explicit scheduler is supplied:features/Phantom.Workspaces.Llm.Core/AgentChat.cs:139-142This binding is intentional: the loop mutates non-thread-safe, UI-observed collections (
RunningItems,History, etc.) viaCreateRunningItem/UpdateRunningItem/CompleteRunningItem, and those mutations must happen on the foreground context.Cause 2 — the streaming enumeration runs synchronously on the loop's thread
RunProcessLoopAsyncpumps the provider enumerator directly and does not offload it:features/Phantom.Workspaces.Llm.Core/AgentChat.cs:1759-1785Each
MoveNextAsyncis invoked from the UI thread (because the loop resumes on the foreground scheduler), and any part of it that runs synchronously — or that resumes synchronously after an already-completed inner await — runs on the UI thread. The middleware body is:features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.cs:48-78Note that
ConfigureAwait(false)here only affects continuations inside the async iterator — it does not change which thread the consumer'sMoveNextAsyncruns on. When the inner client returns an already-buffered update (a fast local await, or the very first synchronous fast-path of a streaming SDK), execution stays on the caller's thread all the way throughbuffer.ToChatResponse()and intoPersistMessageAsync. Because the caller resumed on the Avalonia dispatcher (Cause 1), the heavy MEAI coalescing (FinalizeResponse→CoalesceContent→MergeText→AllocateUninitializedArray<char>) and the persistence-store write execute on the UI thread — exactly the stack shown by the profiler.Additionally,
ToChatResponse()is called on every update, not just at end-of-stream. It re-coalesces the entire growing buffer each time, so per-update UI-thread cost grows with response length — a very large final response coalesces its full text on the UI thread on every intermediate update as well.Affected Files
features/Phantom.Workspaces.Llm.Core/StreamingPersistenceMiddleware.csToChatResponsecoalescing + persistence I/O on the caller's thread; must offload to a worker.features/Phantom.Workspaces.Llm.Core/AgentChat.cs(lines ~1690–1830, andStartProcessingLoopat ~2233–2262)RunProcessLoopAsyncon the foreground scheduler and pumps the provider enumerator directly on that thread. Fix must ensure the streaming enumeration + coalescing does not run on the foreground context, while UI-observable mutations (CreateRunningItem/UpdateRunningItem/CompleteRunningItem,partialResponses.Notify) are still marshalled back to it.features/Phantom.Workspaces.Llm.Core/PartialResponseConflator.cs(indirectly)Design / Fix
Guiding principle (per the owner's comment: "the StreamingPersistenceMiddleware needs to be mindful of foreground / background operations"): keep binding-affecting mutations on the foreground context; move pure compute and I/O off it.
Primary fix — offload the coalescing + persistence work inside
StreamingPersistenceMiddlewareWrap the stream-consumption body in a
Task.Runso the heavy per-update work runs on the thread pool regardless of who calledMoveNextAsync. The idiomatic shape for anIAsyncEnumerable-yielding middleware is a channel + background pump:Ordering constraint preserved: the middleware still writes each stable message before the corresponding update is delivered to the consumer, because the pump only writes to the channel after
PersistMessageAsynccompletes. The consumer still sees updates incrementally (not batched to end-of-stream); the coalescing/persistence cost is simply no longer on the UI thread.Alternative — offload once at the process-loop level
If a wider fix is preferred,
AgentChat.StartProcessingLoopcan launch the enumeration portion ofRunProcessLoopAsyncon the thread pool while keeping the running-item mutations marshalled to the foreground scheduler (e.g. viaRunOnForegroundAsync, already used elsewhere in this file — see comment at lines 388–406). The primary fix above is preferred because it is local to the middleware, cannot regress the existing foreground-context invariants (issues #909 / #1068), and directly addresses the owner's note.What must remain on the foreground context
CreateRunningItem/UpdateRunningItem/CompleteRunningItemand anyrunningItem.Itemsmutation — Avalonia binds to these collections.History.Add/AppendUserMessagesToHistory.PartialResponseConflator.Notify(which internally marshals VM edits) — the loop still calls this from the foreground context; only the upstream production ofChatResponseUpdates moves to the pool.Optional follow-up — avoid per-update full coalescing
buffer.ToChatResponse()is O(total text) per update. Once the heavy work is off the UI thread this is no longer a UX blocker, but a follow-up may want to compute the "message boundary" using role/finish-reason transitions on the buffer directly, callingToChatResponse()only when a boundary or end-of-stream is observed. Out of scope for this fix.Expected Tests
Add to
features/Phantom.Workspaces.Llm.Core.Tests/StreamingPersistenceMiddlewareTests.cs:StreamingPersistenceMiddleware_CoalescingRunsOffCapturedSynchronizationContextSynchronizationContexton the calling thread, invokeGetStreamingResponseAsyncfrom that thread, feed severalChatResponseUpdates through a fake inner client, and assert that every observedSynchronizationContext.Currentsampled inside theChatResponseExtensions.ToChatResponsepath (via anAIContent/inner-client hook that recordsSynchronizationContext.Currentat coalesce time) is eithernullor the default (thread-pool) context — never the tracking context.StreamingPersistenceMiddleware_PersistMessageAsyncRunsOffCapturedSynchronizationContextIAgentPersistenceStore.StoreAsyncobservesSynchronizationContext.Currentoff the tracking context on every stable-message write.StreamingPersistenceMiddleware_YieldOrdering_StableMessagePersistedBeforeNextUpdateYieldedTwoUpdates_FirstPersistedBeforeSecondYielded) must continue to hold after offloading — verify explicitly that when message N becomes stable,StoreAsync(N)completes beforeMoveNextAsyncreturns update N+1 to the consumer.StreamingPersistenceMiddleware_CancellationTokenPropagates_ToInnerEnumerationAndPumpOperationCanceledException(or equivalent), matching the current behaviour.AgentChat_StreamingRun_ForegroundContextObservesOnlyVmMutationsSynchronizationContextTaskSchedulerinstalled, run a scripted streaming response throughAgentChat; assert thatRunningItems/Historyobservable mutations run on the foreground context (existing invariant), while a hook placed on the inner chat client records thatToChatResponse/StoreAsyncdid not run on it.Naming follows the
Subject_Scenario_ExpectedOutcomePascalCase convention already used inStreamingPersistenceMiddlewareTests(e.g.SingleUpdate_FinishReason_PersistedOnStreamEnd,TwoUpdates_FirstPersistedBeforeSecondYielded).