Repository navigation
Bug: AgentChat is constructed and initialized off the UI thread; UI-thread affinity must be an enforced invariant #909
Description
Bug: AgentChat is constructed and initialized off the UI thread — UI-thread affinity must be an enforced invariant
Owner directive
Reviewing the fix for #908, the repository owner states:
Why is AgentChat ever being created on a non-UI thread? That's a bug. InitializeAsync, etc., should all be invoked on UI threads. And especially AgentChat constructor.
#908 compensated downstream (running the process loop via Task.Factory.StartNew(..., foregroundScheduler)), but the upstream defect remains: several call sites construct/initialize AgentChat from background threads, and nothing enforces the affinity invariant, so regressions like #908 (and the #807/#70 history behind foregroundScheduler) stay silently possible.
Root cause — call sites that create AgentChat off the UI thread
- Fresh sessions (launchpad) —
Phantom.Workspaces\ViewModels\AgentManifestLaunchpadViewModel.csStartSessionAsync(lines ~190 and ~221):_ = Task.Run(async () => { ... await AgentFactory.CreateAgentChatAsync(...) ... })for both the manifest and definition sources. The entire chat construction — not just background I/O — runs on a thread-pool thread; only the pre-capturedTaskScheduler.FromCurrentSynchronizationContext()kept things (mostly) working. - Loaded/resumed sessions and tool-created sessions (factory) —
Phantom.Workspaces.Llm.Core\AgentChatFactory.csGetOrCreateAsync/GetAsync/CreateAsync(lines 80, 122, 174):AgentChat.CreateAsyncexecutes on whatever thread invoked the factory. The factory is reached from GUI handlers (OpenAgentSessionShortcutHandler.TryBuildAgentAsync→RunningAgentChatTable.AcquireAsync, auto-resume viaMainWindowViewModel.InitializeAutoResumeAsync→TryStartAutoResumeAsync) and from tool contexts (AgentSessionToolsetFactoryviaIRunningAgentChatFactory), which run mid-pipeline on thread-pool threads. Although the factory holds a_foregroundSchedulerand documents that "All RunningSessions mutations are dispatched on the foreground scheduler", chat construction itself is not bound to it. - Unverifiable invariant everywhere else —
OpenAgentSessionShortcutHandler(lines 82, 147),AgentManifestLaunchpadViewModel(line 185),MainWindowViewModel(line 641), andApp.axaml.cs(line 256) captureTaskScheduler.FromCurrentSynchronizationContext()— a BCL scheduler that does not expose itsSynchronizationContext, soAgentChathas no way to verify that construction is happening on the captured context.CreateAgentChatRequest.ForegroundScheduler's doc comment even endorses the buggy pattern: "pass it here whenever the agent chat is constructed off the UI thread (e.g. insideTask.Run)".
AgentChat's constructor (Phantom.Workspaces.Llm.Core\AgentChat.cs:110-123) captures/accepts the foreground scheduler but performs no affinity verification, so all of the above went undetected until the #908 symptom.
Enforcement design
AgentChat is also used by Phantom.Workspaces.Agent.Cli and many headless tests with no Avalonia UI thread, so the invariant is expressed in terms of the provided foreground scheduler / synchronization context — not Dispatcher.UIThread:
- New
SynchronizationContextTaskScheduler(Phantom.Workspaces.Llm.Core) — a single-concurrencyTaskSchedulerthat posts to a capturedSynchronizationContextand exposes it, making the affinity invariant verifiable (unlike the BCLTaskScheduler.FromCurrentSynchronizationContext()). StaticFromCurrent()factory. - Verify-and-throw in the
AgentChatconstructor (which also guardsCreateAsync/InitializeAsync, invoked synchronously after it): whenrequest.ForegroundScheduleris aSynchronizationContextTaskScheduler, throwInvalidOperationExceptionunless the current thread is on that context (SynchronizationContext.Currentequals its context, orTaskScheduler.Currentis the scheduler itself). No silent marshalling — caller bugs fail fast. - GUI capture sites switch to the new scheduler type (
App.axaml.cs,OpenAgentSessionShortcutHandler,AgentManifestLaunchpadViewModel,MainWindowViewModelauto-resume), so all GUI-context creations are enforced. - Fix the launchpad: remove both
Task.Runwrappers inStartSessionAsync— the work is already fully async; it runs on the UI thread without blocking it. - Fix the factory structurally:
AgentChatFactoryschedulesAgentChat.CreateAsynconto its_foregroundSchedulerviaTask.Factory.StartNew(...).Unwrap(). This is not defensive masking: the factory owns the foreground scheduler, its contract already promises foreground dispatch of its observable state, and it is legitimately invoked from non-UI contexts (agent tools creating sub-sessions) that have no UI thread of their own to marshal from. - Headless story preserved: a plain
TaskScheduler(e.g.TaskScheduler.Default, test schedulers) carries no verifiable thread affinity — "being on" the thread-pool scheduler is meaningless — so no check applies. CLI/tests that pass no scheduler get the capture-from-current behavior, which is on-context by construction. - #908 mechanism kept:
StartProcessingLoopcontinues to invoke the loop insideStartNew(..., foregroundScheduler)— even on-context construction (viaSynchronizationContext) leavesTaskScheduler.Current == Default, so the explicit scheduler binding remains the mechanism that gives the loop and its await continuations foreground affinity. Comment updated; #908's three regression tests must remain green.
Alternatives considered
- Auto-marshal construction inside
AgentChat(wrapCreateAsyncbody inStartNewon the foreground scheduler): rejected — silently masks caller bugs; the owner's directive implies verify-and-throw for the constructor andInitializeAsync. - Require
TaskScheduler.Current == ForegroundSchedulerfor every provided scheduler: rejected — impossible to satisfy on a UI thread outside a scheduled task (after anyawait,TaskScheduler.Currentreverts toDefaultwhileSynchronizationContext.Currentpersists), and meaningless forTaskScheduler.Defaultand deliberately-detached test schedulers (CapturingTaskSchedulerand friends). - Hard-code
Avalonia.Threading.Dispatcher.UIThread.VerifyAccess(): rejected —Phantom.Workspaces.Llm.Coreis UI-framework-agnostic and used headless by the CLI and tests.
Expected tests
| Test | Project |
|---|---|
Constructor_SynchronizationContextSchedulerOffContext_ThrowsInvalidOperationException |
Phantom.Workspaces.Llm.Core.Tests |
CreateAsync_SynchronizationContextSchedulerOffContext_ThrowsInvalidOperationException |
Phantom.Workspaces.Llm.Core.Tests |
CreateAsync_OnMatchingSynchronizationContext_Succeeds |
Phantom.Workspaces.Llm.Core.Tests |
CreateAsync_StartedOnForegroundScheduler_Succeeds |
Phantom.Workspaces.Llm.Core.Tests |
CreateAsync_PlainSchedulerOffContext_DoesNotEnforceAffinity |
Phantom.Workspaces.Llm.Core.Tests |
SynchronizationContextTaskScheduler_QueueTask_ExecutesOnCapturedContext |
Phantom.Workspaces.Llm.Core.Tests |
GetOrCreateAsync_CalledOffForegroundContext_CreatesChatOnForegroundContext |
Phantom.Workspaces.Llm.Core.Tests |
GetAsync_CalledOffForegroundContext_CreatesChatOnForegroundContext |
Phantom.Workspaces.Llm.Core.Tests |
CreateAsync_CalledOffForegroundContext_CreatesChatOnForegroundContext |
Phantom.Workspaces.Llm.Core.Tests |
AgentManifestLaunchpad_StartSessionWithParameters_CreatesAgentChatOnUIThread |
Phantom.Workspaces.Tests |
OpenAgentSessionShortcutHandler_Handle_CreatesAgentChatOnUIThread |
Phantom.Workspaces.Tests |
Plus: #908's three regression tests (StartProcessingLoop_ChatCreatedOnBackgroundThread_HistoryMutationsRunOnForegroundScheduler, StartProcessingLoop_ChatCreatedOnBackgroundThread_RunningItemMutationsRunOnForegroundScheduler, AgentChatOutputControl_ChatCreatedOnBackgroundThread_LiveTurnPostsOnUIThread) and #807's hosted-loop tests must remain green.
Activity
JoshuaRowePhantom commentedon Jul 13, 2026
Design
Call-site inventory (who creates
AgentChat, and on which thread)AgentManifestLaunchpadViewModel.StartSessionAsync(~190, ~221) — fresh manifest/definition sessionsTask.RunaroundAgentFactory.CreateAgentChatAsyncAgentChatFactory.GetOrCreateAsync/GetAsync/CreateAsync(80, 122, 174)_foregroundScheduler; tool callers (AgentSessionToolsetFactoryviaIRunningAgentChatFactory) are on pool threadsOpenAgentSessionShortcutHandler.TryBuildAgentAsync/TryStartAutoResumeAsync/TryCreateAgentSessionTabForRestoreAsyncViewModelLifetime.Run, but the invariant is unverified and the captured BCL scheduler makes verification impossibleStartAgentSessionOnProfileViewModel.CreateDefinitionSessionAsyncAgentFactory.CreateAgentChatAsync/ trust executors /AgentChatSessionCache/AgentChat.GetOrCreateAsync(sub-agent child, 773)Phantom.Workspaces.Agent.Cli\Program.cs:74, test suitesEnforcement design
SynchronizationContextTaskSchedulerinPhantom.Workspaces.Llm.Core: posts to a capturedSynchronizationContextand exposes it, so "am I on the foreground context?" becomes verifiable (the BCLTaskScheduler.FromCurrentSynchronizationContext()hides its context).AgentChatconstructor (guardingCreateAsync/InitializeAsync, which follow synchronously): whenrequest.ForegroundScheduleris aSynchronizationContextTaskSchedulerand the current thread is neither on itsSynchronizationContextnor executing on the scheduler itself → throwInvalidOperationException. Fail fast; no silent marshalling.TaskScheduler.Default, test schedulers) carry no verifiable affinity → unchecked; CLI/headless usage is unaffected.Task.Runwrappers removed (work is fully async — runs on the UI thread without blocking);AgentChatFactoryschedules creation onto its own_foregroundScheduler(StartNew(...).Unwrap()) because it owns the scheduler and is legitimately invoked from non-UI tool contexts.StartProcessingLoopStartNewbinding stays — it remains the mechanism that gives the loopTaskScheduleraffinity (on-context construction viaSynchronizationContextstill leavesTaskScheduler.Current == Default). Comment updated to reflect the now-enforced invariant.Alternatives considered
AgentChat.CreateAsync— rejected: masks caller bugs; owner directive implies verify-and-throw.TaskScheduler.Current == ForegroundScheduleruniversally — rejected: unsatisfiable on a UI thread after anyawait, meaningless forTaskScheduler.Default, breaks deliberate test schedulers.Dispatcher.UIThread.VerifyAccess()— rejected:Llm.Coreis UI-framework-agnostic (CLI/tests).Files to change
Phantom.Workspaces.Llm.Core\SynchronizationContextTaskScheduler.cs(new)Phantom.Workspaces.Llm.Core\AgentChat.cs— constructor verification;StartProcessingLoopcommentPhantom.Workspaces.Llm.Core\AgentChatFactory.cs— bind creation to_foregroundSchedulerPhantom.Workspaces.Llm.Core\CreateAgentChatRequest.cs— fixForegroundSchedulerdoc (currently endorses theTask.Runpattern)Phantom.Workspaces\App.axaml.cs,ViewModels\OpenAgentSessionShortcutHandler.cs,ViewModels\AgentManifestLaunchpadViewModel.cs,ViewModels\MainWindowViewModel.cs— switch captures to the new scheduler type; remove launchpadTask.RunPhantom.Workspaces.Llm.Core.Tests\AgentChatForegroundContextTests.cs(new),AgentChatFactoryTests.cs,Phantom.Workspaces.Tests\MainWindowIntegrationTests.csJoshuaRowePhantom commentedon Jul 13, 2026
Tests
All deterministic (no
Task.Delay/Thread.Sleep; synchronization via single-threaded context pumps and existingWaitFor*helpers). Enforcement tests fail before the fix; call-site tests fail once enforcement lands but before the call sites are fixed.Phantom.Workspaces.Llm.Core.Tests — new
AgentChatForegroundContextTests.csConstructor_SynchronizationContextSchedulerOffContext_ThrowsInvalidOperationException— directnew AgentChat(request)on the test thread with aSynchronizationContextTaskSchedulerbound to a different (pumped) context →InvalidOperationException.CreateAsync_SynchronizationContextSchedulerOffContext_ThrowsInvalidOperationException—AgentChat.CreateAsyncinsideTask.Runwith the same off-context scheduler →InvalidOperationException(coversInitializeAsync, which follows the constructor synchronously).CreateAsync_OnMatchingSynchronizationContext_Succeeds— creation posted onto the pumped context that backs the scheduler → succeeds; chat processes an echo turn.CreateAsync_StartedOnForegroundScheduler_Succeeds— creation invoked viaTask.Factory.StartNew(..., scheduler)(soTaskScheduler.Currentmatches) → succeeds.CreateAsync_PlainSchedulerOffContext_DoesNotEnforceAffinity—TaskScheduler.Defaultpassed from insideTask.Run→ succeeds (CLI/headless story preserved).SynchronizationContextTaskScheduler_QueueTask_ExecutesOnCapturedContext— unit test of the new scheduler: queued work executes on the captured context's thread;MaximumConcurrencyLevel == 1.Phantom.Workspaces.Llm.Core.Tests —
AgentChatFactoryTests.csGetOrCreateAsync_CalledOffForegroundContext_CreatesChatOnForegroundContextGetAsync_CalledOffForegroundContext_CreatesChatOnForegroundContextCreateAsync_CalledOffForegroundContext_CreatesChatOnForegroundContext— factory constructed with a
SynchronizationContextTaskSchedulerover a pumped context; each method invoked fromTask.Run; pre-fix the enforced constructor throws (creation runs on the calling pool thread); post-fix creation is scheduled onto the foreground scheduler and succeeds, and the chat's foreground scheduler is the factory's.Phantom.Workspaces.Tests —
MainWindowIntegrationTests.cs([PhantomAvaloniaFact(Timeout = 15_000)])AgentManifestLaunchpad_StartSessionWithParameters_CreatesAgentChatOnUIThread— opens a parametered manifest launchpad, fills the parameter, executesStartSessionCommand; waits for the session tab to become ready. With enforcement active and the launchpad captures switched toSynchronizationContextTaskScheduler, this fails while theTask.Runwrappers remain (creation off-context throws → tabSetFailed) and passes once creation runs on the UI thread.OpenAgentSessionShortcutHandler_Handle_CreatesAgentChatOnUIThread— opens an existing agent-session entity through the shortcut handler (withRunningAgentChatTable); waits for the tab to become ready, verifying the loaded-session path satisfies the enforced invariant end-to-end.Regression suites that must stay green
StartProcessingLoop_ChatCreatedOnBackgroundThread_HistoryMutationsRunOnForegroundScheduler,StartProcessingLoop_ChatCreatedOnBackgroundThread_RunningItemMutationsRunOnForegroundScheduler(Llm.Core.Tests),AgentChatOutputControl_ChatCreatedOnBackgroundThread_LiveTurnPostsOnUIThread(Agent.Gui.Tests) — unchanged; they use plain/BCL schedulers, which remain unverified by design.AgentChatHostedSubAgentTests), Live chat output: user messages and agent output not appearing without refresh #901 replay tests,StreamingRun_SerializesRunningItemMutationsOnForegroundScheduler, and the existing shortcut-handler/launchpad integration tests, which now double as enforcement coverage.JoshuaRowePhantom commentedon Jul 13, 2026
Fixed in commit 11946af on branch \ix/agentchat-ui-thread-creation. Full fast suite: Failed: 0 across all suites except the 3 known ConPtyPseudoTerminalTests failures (#895). New tests: 6 AgentChatForegroundContextTests, 3 AgentChatFactoryTests off-context tests, 2 MainWindowIntegrationTests UI-thread-creation tests. #908's StartProcessingLoop mechanism retained (TaskScheduler.Current is Default even on-context).
Fix #909: Enforce AgentChat foreground-context construction affinity
Fix #913: flow the foreground scheduler to registry-path sub-agent ch…
JoshuaRowePhantom commentedon Jul 14, 2026
Verification passed
Checked: Commit 11946af on the eatures branch (Fix #909).
Files inspected:
No disabled/quarantined tests, no Task.Delay/Thread.Sleep timing dependencies, and no unresolved TODOs in new code.
Result: Implementation found, all verification criteria satisfied.
Fix #1098: marshal AgentChat init history/session load onto the foreg…
Fix #1128: force restored SDK sub-agents to Succeeded on reload