Skip to content

Bug: AgentChat is constructed and initialized off the UI thread; UI-thread affinity must be an enforced invariant #909

Closed

Description

@JoshuaRowePhantom

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

  1. Fresh sessions (launchpad) — Phantom.Workspaces\ViewModels\AgentManifestLaunchpadViewModel.cs StartSessionAsync (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-captured TaskScheduler.FromCurrentSynchronizationContext() kept things (mostly) working.
  2. Loaded/resumed sessions and tool-created sessions (factory) — Phantom.Workspaces.Llm.Core\AgentChatFactory.cs GetOrCreateAsync/GetAsync/CreateAsync (lines 80, 122, 174): AgentChat.CreateAsync executes on whatever thread invoked the factory. The factory is reached from GUI handlers (OpenAgentSessionShortcutHandler.TryBuildAgentAsync → RunningAgentChatTable.AcquireAsync, auto-resume via MainWindowViewModel.InitializeAutoResumeAsync → TryStartAutoResumeAsync) and from tool contexts (AgentSessionToolsetFactory via IRunningAgentChatFactory), which run mid-pipeline on thread-pool threads. Although the factory holds a _foregroundScheduler and documents that "All RunningSessions mutations are dispatched on the foreground scheduler", chat construction itself is not bound to it.
  3. Unverifiable invariant everywhere else — OpenAgentSessionShortcutHandler (lines 82, 147), AgentManifestLaunchpadViewModel (line 185), MainWindowViewModel (line 641), and App.axaml.cs (line 256) capture TaskScheduler.FromCurrentSynchronizationContext() — a BCL scheduler that does not expose its SynchronizationContext, so AgentChat has 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. inside Task.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:

  1. New SynchronizationContextTaskScheduler (Phantom.Workspaces.Llm.Core) — a single-concurrency TaskScheduler that posts to a captured SynchronizationContext and exposes it, making the affinity invariant verifiable (unlike the BCL TaskScheduler.FromCurrentSynchronizationContext()). Static FromCurrent() factory.
  2. Verify-and-throw in the AgentChat constructor (which also guards CreateAsync/InitializeAsync, invoked synchronously after it): when request.ForegroundScheduler is a SynchronizationContextTaskScheduler, throw InvalidOperationException unless the current thread is on that context (SynchronizationContext.Current equals its context, or TaskScheduler.Current is the scheduler itself). No silent marshalling — caller bugs fail fast.
  3. GUI capture sites switch to the new scheduler type (App.axaml.cs, OpenAgentSessionShortcutHandler, AgentManifestLaunchpadViewModel, MainWindowViewModel auto-resume), so all GUI-context creations are enforced.
  4. Fix the launchpad: remove both Task.Run wrappers in StartSessionAsync — the work is already fully async; it runs on the UI thread without blocking it.
  5. Fix the factory structurally: AgentChatFactory schedules AgentChat.CreateAsync onto its _foregroundScheduler via Task.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.
  6. 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.
  7. #908 mechanism kept: StartProcessingLoop continues to invoke the loop inside StartNew(..., foregroundScheduler) — even on-context construction (via SynchronizationContext) leaves TaskScheduler.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 (wrap CreateAsync body in StartNew on the foreground scheduler): rejected — silently masks caller bugs; the owner's directive implies verify-and-throw for the constructor and InitializeAsync.
  • Require TaskScheduler.Current == ForegroundScheduler for every provided scheduler: rejected — impossible to satisfy on a UI thread outside a scheduled task (after any await, TaskScheduler.Current reverts to Default while SynchronizationContext.Current persists), and meaningless for TaskScheduler.Default and deliberately-detached test schedulers (CapturingTaskScheduler and friends).
  • Hard-code Avalonia.Threading.Dispatcher.UIThread.VerifyAccess(): rejected — Phantom.Workspaces.Llm.Core is 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.

Links

Follow-up to #908; history: #807, #70.

Activity

  1. JoshuaRowePhantom commented on Jul 13, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Design

    Call-site inventory (who creates AgentChat, and on which thread)

    Call site Context On UI thread today?
    AgentManifestLaunchpadViewModel.StartSessionAsync (~190, ~221) — fresh manifest/definition sessions GUI No — explicit Task.Run around AgentFactory.CreateAgentChatAsync
    AgentChatFactory.GetOrCreateAsync / GetAsync / CreateAsync (80, 122, 174) Service (GUI + tools) Caller-dependent — unbound to _foregroundScheduler; tool callers (AgentSessionToolsetFactory via IRunningAgentChatFactory) are on pool threads
    OpenAgentSessionShortcutHandler.TryBuildAgentAsync / TryStartAutoResumeAsync / TryCreateAgentSessionTabForRestoreAsync GUI (loaded/restore/auto-resume) Starts on UI via ViewModelLifetime.Run, but the invariant is unverified and the captured BCL scheduler makes verification impossible
    StartAgentSessionOnProfileViewModel.CreateDefinitionSessionAsync GUI Yes (no scheduler passed → capture-from-current)
    AgentFactory.CreateAgentChatAsync / trust executors / AgentChatSessionCache / AgentChat.GetOrCreateAsync (sub-agent child, 773) Headless-capable No explicit scheduler → capture-from-current, on-context by construction
    Phantom.Workspaces.Agent.Cli\Program.cs:74, test suites Headless No UI thread exists — must keep working

    Enforcement design

    • New SynchronizationContextTaskScheduler in Phantom.Workspaces.Llm.Core: posts to a captured SynchronizationContext and exposes it, so "am I on the foreground context?" becomes verifiable (the BCL TaskScheduler.FromCurrentSynchronizationContext() hides its context).
    • AgentChat constructor (guarding CreateAsync/InitializeAsync, which follow synchronously): when request.ForegroundScheduler is a SynchronizationContextTaskScheduler and the current thread is neither on its SynchronizationContext nor executing on the scheduler itself → throw InvalidOperationException. Fail fast; no silent marshalling.
    • Plain schedulers (TaskScheduler.Default, test schedulers) carry no verifiable affinity → unchecked; CLI/headless usage is unaffected.
    • GUI capture points switch to the new type; launchpad Task.Run wrappers removed (work is fully async — runs on the UI thread without blocking); AgentChatFactory schedules creation onto its own _foregroundScheduler (StartNew(...).Unwrap()) because it owns the scheduler and is legitimately invoked from non-UI tool contexts.
    • Live text updates from a loaded session never reach the chat view: AgentChat process loop runs off the foreground scheduler #908's StartProcessingLoop StartNew binding stays — it remains the mechanism that gives the loop TaskScheduler affinity (on-context construction via SynchronizationContext still leaves TaskScheduler.Current == Default). Comment updated to reflect the now-enforced invariant.

    Alternatives considered

    • Auto-marshal inside AgentChat.CreateAsync — rejected: masks caller bugs; owner directive implies verify-and-throw.
    • Require TaskScheduler.Current == ForegroundScheduler universally — rejected: unsatisfiable on a UI thread after any await, meaningless for TaskScheduler.Default, breaks deliberate test schedulers.
    • Dispatcher.UIThread.VerifyAccess() — rejected: Llm.Core is UI-framework-agnostic (CLI/tests).

    Files to change

    • Phantom.Workspaces.Llm.Core\SynchronizationContextTaskScheduler.cs (new)
    • Phantom.Workspaces.Llm.Core\AgentChat.cs — constructor verification; StartProcessingLoop comment
    • Phantom.Workspaces.Llm.Core\AgentChatFactory.cs — bind creation to _foregroundScheduler
    • Phantom.Workspaces.Llm.Core\CreateAgentChatRequest.cs — fix ForegroundScheduler doc (currently endorses the Task.Run pattern)
    • Phantom.Workspaces\App.axaml.cs, ViewModels\OpenAgentSessionShortcutHandler.cs, ViewModels\AgentManifestLaunchpadViewModel.cs, ViewModels\MainWindowViewModel.cs — switch captures to the new scheduler type; remove launchpad Task.Run
    • Tests: Phantom.Workspaces.Llm.Core.Tests\AgentChatForegroundContextTests.cs (new), AgentChatFactoryTests.cs, Phantom.Workspaces.Tests\MainWindowIntegrationTests.cs
  2. JoshuaRowePhantom commented on Jul 13, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Tests

    All deterministic (no Task.Delay/Thread.Sleep; synchronization via single-threaded context pumps and existing WaitFor* 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.cs

    1. Constructor_SynchronizationContextSchedulerOffContext_ThrowsInvalidOperationException — direct new AgentChat(request) on the test thread with a SynchronizationContextTaskScheduler bound to a different (pumped) context → InvalidOperationException.
    2. CreateAsync_SynchronizationContextSchedulerOffContext_ThrowsInvalidOperationException — AgentChat.CreateAsync inside Task.Run with the same off-context scheduler → InvalidOperationException (covers InitializeAsync, which follows the constructor synchronously).
    3. CreateAsync_OnMatchingSynchronizationContext_Succeeds — creation posted onto the pumped context that backs the scheduler → succeeds; chat processes an echo turn.
    4. CreateAsync_StartedOnForegroundScheduler_Succeeds — creation invoked via Task.Factory.StartNew(..., scheduler) (so TaskScheduler.Current matches) → succeeds.
    5. CreateAsync_PlainSchedulerOffContext_DoesNotEnforceAffinity — TaskScheduler.Default passed from inside Task.Run → succeeds (CLI/headless story preserved).
    6. 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.cs

    1. GetOrCreateAsync_CalledOffForegroundContext_CreatesChatOnForegroundContext
    2. GetAsync_CalledOffForegroundContext_CreatesChatOnForegroundContext
    3. CreateAsync_CalledOffForegroundContext_CreatesChatOnForegroundContext
      — factory constructed with a SynchronizationContextTaskScheduler over a pumped context; each method invoked from Task.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)])

    1. AgentManifestLaunchpad_StartSessionWithParameters_CreatesAgentChatOnUIThread — opens a parametered manifest launchpad, fills the parameter, executes StartSessionCommand; waits for the session tab to become ready. With enforcement active and the launchpad captures switched to SynchronizationContextTaskScheduler, this fails while the Task.Run wrappers remain (creation off-context throws → tab SetFailed) and passes once creation runs on the UI thread.
    2. OpenAgentSessionShortcutHandler_Handle_CreatesAgentChatOnUIThread — opens an existing agent-session entity through the shortcut handler (with RunningAgentChatTable); 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

  3. JoshuaRowePhantom commented on Jul 13, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    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).

  4. added 2 commits that reference this issue on Jul 13, 2026
    11946af
    46ab842
  5. JoshuaRowePhantom commented on Jul 14, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Verification passed

    Checked: Commit 11946af on the eatures branch (Fix #909).

    Files inspected:

    • Phantom.Workspaces.Llm.Core/SynchronizationContextTaskScheduler.cs (new) — exposes SynchronizationContext, IsOnSynchronizationContext, FromCurrent(), MaximumConcurrencyLevel == 1.
    • Phantom.Workspaces.Llm.Core/AgentChat.cs — VerifyOnForegroundContext called in constructor; throws InvalidOperationException when a SynchronizationContextTaskScheduler is provided but the current thread is not on its context and TaskScheduler.Current is not that scheduler. Plain schedulers remain unchecked.
    • Phantom.Workspaces.Llm.Core/AgentChatFactory.cs — all three creation paths (GetOrCreateAsync, GetAsync, CreateAsync) delegate to CreateChatOnForegroundAsync, which runs AgentChat.CreateAsync via Task.Factory.StartNew(..., _foregroundScheduler).Unwrap().
    • Phantom.Workspaces/App.axaml.cs, ViewModels/OpenAgentSessionShortcutHandler.cs, ViewModels/MainWindowViewModel.cs — capture sites switched to SynchronizationContextTaskScheduler.FromCurrent().
    • Phantom.Workspaces/ViewModels/AgentManifestLaunchpadViewModel.cs — Task.Run wrappers removed; creation runs on the UI thread via Lifetime.Run.
    • Phantom.Workspaces.Llm.Core.Tests/AgentChatForegroundContextTests.cs (new, 6 tests) — all 6 described tests present and deterministic (event-driven sync via TaskCompletionSource + CollectionChanged).
    • Phantom.Workspaces.Llm.Core.Tests/AgentChatFactoryTests.cs — 3 off-foreground-context tests present.
    • Phantom.Workspaces.Tests/MainWindowIntegrationTests.cs — 2 UI-thread-creation integration tests present (AgentManifestLaunchpad_StartSessionWithParameters_CreatesAgentChatOnUIThread, OpenAgentSessionShortcutHandler_Handle_CreatesAgentChatOnUIThread).

    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.

  6. added a commit that references this issue on Jul 17, 2026
  7. added a commit that references this issue on Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdiagnosedRoot cause identifiedverifiedIssue has been verified

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions