Skip to content

Add IRunningAgentChatFactory interface and AgentChatFactory implementation to Llm.Core #670

Closed

Description

@JoshuaRowePhantom

Summary

Introduce IRunningAgentChatFactory in Phantom.Workspaces.Llm.Core and its reference-counted implementation AgentChatFactory. Adds IRunningAgentChatFactory? RunningAgentChatFactory to AgentServices. On first GetAsync for a session ID the factory loads AgentChat from IAgentPersistenceStore, acquires an anchor lease, and adds a RunningAgentChat to RunningSessions. On last lease release it removes the entry and disposes AgentChat. CreateAsync persists the new session before adding to the table.

Part of #659.


Prerequisites

  • #667 — AgentSessionId typed wrapper
  • #668 — RunningAgentChatLease in Llm.Core
  • #669 — RunningAgentChat entry type

Design

Interface

// Phantom.Workspaces.Llm.Core/IRunningAgentChatFactory.cs
public interface IRunningAgentChatFactory
{
    /// <summary>
    /// The live set of sessions currently held by at least one lease.
    /// Mutations are dispatched on the foreground scheduler; UI subscribers need not marshal.
    /// </summary>
    ObservableCollection<RunningAgentChat> RunningSessions { get; }

    /// <summary>
    /// Acquires a ref-counted lease on the AgentChat for <paramref name="sessionId"/>,
    /// loading it from persistence if not already running.
    /// </summary>
    Task<RunningAgentChatLease> GetAsync(AgentSessionId sessionId, CancellationToken ct = default);

    /// <summary>
    /// Creates a new AgentChat from <paramref name="definition"/> + <paramref name="sessionId"/>,
    /// persists it, adds to RunningSessions, and returns a lease.
    /// </summary>
    Task<RunningAgentChatLease> CreateAsync(
        AgentDefinition definition,
        AgentSessionId sessionId,
        AgentServices? services = null,
        CancellationToken ct = default);
}

Implementation

// Phantom.Workspaces.Llm.Core/AgentChatFactory.cs
internal sealed class AgentChatFactory : IRunningAgentChatFactory, IAsyncDisposable
{
    private readonly IAgentPersistenceStore _store;
    private readonly AgentServices _services;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private readonly Dictionary<AgentSessionId, Entry> _entries = new();
    private readonly ObservableCollection<RunningAgentChat> _runningSessions = new();

    public ObservableCollection<RunningAgentChat> RunningSessions => _runningSessions;

    public Task<RunningAgentChatLease> GetAsync(AgentSessionId sessionId, CancellationToken ct = default) { … }
    public Task<RunningAgentChatLease> CreateAsync(AgentDefinition definition, AgentSessionId sessionId, AgentServices? services = null, CancellationToken ct = default) { … }
}

AgentServices change

// Phantom.Workspaces.Llm.Interfaces/AgentServices.cs
public IRunningAgentChatFactory? RunningAgentChatFactory { get; init; }

AgentChat receives IRunningAgentChatFactory via AgentServices at construction and passes it to SubAgent instances (see #658).

Threading

All mutations to RunningSessions (Add and Remove) must be posted to the foreground TaskScheduler (the UI thread scheduler). The factory must accept a TaskScheduler foregroundScheduler constructor parameter. This makes the threading contract testable without requiring a real UI thread.

At production call sites, pass TaskScheduler.FromCurrentSynchronizationContext() captured on the UI thread before the factory is constructed.

In tests, pass TaskScheduler.Default (or a custom capturing scheduler) to run mutations synchronously on the test thread.


Files affected

File Change
New: Phantom.Workspaces.Llm.Core/IRunningAgentChatFactory.cs New interface with GetAsync + CreateAsync
New: Phantom.Workspaces.Llm.Core/AgentChatFactory.cs Reference-counted implementation
Phantom.Workspaces.Llm.Interfaces/AgentServices.cs Add IRunningAgentChatFactory? RunningAgentChatFactory
Phantom.Workspaces.Llm.Core/AgentChat.cs Accept IRunningAgentChatFactory via AgentServices

Expected tests

Test name What it verifies
GetAsync_NewSession_LoadsFromPersistenceAndReturnsLease When the session ID is not in memory, GetAsync loads AgentChat from IAgentPersistenceStore and returns a valid lease
GetAsync_NewSession_AppearsInRunningSessions After the first GetAsync, the session appears in RunningSessions
GetAsync_SessionAlreadyRunning_ReturnsSameAgentChatNewLease A second GetAsync for the same ID returns a new lease wrapping the same AgentChat instance
GetAsync_AfterLastLeaseDisposed_RemovesFromRunningSessions When the last lease is disposed, the session is removed from RunningSessions
GetAsync_AfterLastLeaseDisposed_DisposesAgentChat When the last lease is disposed, the AgentChat is disposed
GetAsync_MultipleLeases_RemovedOnlyAfterAllDisposed With two outstanding leases, the session remains in RunningSessions until both are disposed
GetAsync_ConcurrentFirstAcquire_OnlyCreatesOneAgentChat Concurrent GetAsync calls for the same session ID create exactly one AgentChat (gate serialises)
CreateAsync_PersistsSessionBeforeReturning CreateAsync calls IAgentPersistenceStore to persist the definition before returning the lease
CreateAsync_NewSession_AppearsInRunningSessions After CreateAsync, the new session appears in RunningSessions
CreateAsync_DuplicateSessionId_ThrowsInvalidOperationException Calling CreateAsync with an already-running session ID throws (duplicate creation is a caller error)
RunningSessions_Add_HappensOnForegroundScheduler AgentChatFactory is constructed with a capturing test scheduler; after GetAsync, the Add mutation to RunningSessions is observed on that scheduler
RunningSessions_Remove_HappensOnForegroundScheduler After the last lease is disposed, the Remove mutation to RunningSessions is observed on the foreground scheduler
DisposeAsync_ReleasesAllSessions Disposing the factory disposes all outstanding AgentChat instances and clears RunningSessions

Activity

  1. JoshuaRowePhantom commented on Jul 3, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Design

    Approach: Expand the IRunningAgentChatFactory stub from #669 with RunningSessions and CreateAsync, then implement AgentChatFactory as a reference-counted session table.

    IRunningAgentChatFactory

    • Add ObservableCollection RunningSessions { get; }
    • Add CreateAsync(AgentDefinition, AgentSessionId, AgentServices?, CancellationToken) returning Task

    AgentChatFactory

    • Constructor: (IAgentPersistenceStore store, AgentServices services, TaskScheduler foregroundScheduler)
    • SemaphoreSlim _gate = new(1,1) guards _entries Dictionary
    • Dictionary<AgentSessionId, Entry> where Entry holds AgentChat + int RefCount
    • GetAsync: while holding gate — check existing entry (increment ref count + return lease) or load from persistence via InternalCreateAgentChatRequest{AgentDefinition=null, AgentSessionId=...}, add entry, release gate, then post RunningSessions.Add to foreground scheduler
    • CreateAsync: while holding gate — persist definition first via IAgentPersistenceStore.StoreAsync, create AgentChat, add entry, release gate, then post RunningSessions.Add to foreground scheduler; throws InvalidOperationException if session already running
    • Lease dispose callback (ReleaseAsync): acquire gate, decrement ref count; if zero, remove entry + release gate, then post RunningSessions.Remove to foreground scheduler + call AgentChat.DisposeAsync()
    • DisposeAsync: acquire gate, collect all entries + clear _entries, release gate, then �wait all AgentChat.DisposeAsync() in parallel
    • RunningSessions mutations always posted to _foregroundScheduler (gate released first to avoid deadlock with foreground-thread callers)

    AgentServices

    • Add IRunningAgentChatFactory? RunningAgentChatFactory { get; init; }

    FakeRunningAgentChatFactory (in test)

    • Update to implement the expanded interface

    Files

    • Phantom.Workspaces.Llm.Core/IRunningAgentChatFactory.cs — expand interface
    • Phantom.Workspaces.Llm.Core/AgentChatFactory.cs — new implementation
    • Phantom.Workspaces.Llm.Interfaces/AgentServices.cs — add property
    • Phantom.Workspaces.Llm.Core.Tests/RunningAgentChatTests.cs — update FakeRunningAgentChatFactory
    • Phantom.Workspaces.Llm.Core.Tests/AgentChatFactoryTests.cs — new test class
  2. JoshuaRowePhantom commented on Jul 3, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Tests

    All in Phantom.Workspaces.Llm.Core.Tests/AgentChatFactoryTests.cs:

    • GetAsync_NewSession_LoadsFromPersistenceAndReturnsLease — calls GetAsync with a session pre-populated in store; lease is non-null with correct session ID
    • GetAsync_NewSession_AppearsInRunningSessions — after GetAsync, RunningSessions contains one entry with the session ID
    • GetAsync_SessionAlreadyRunning_ReturnsSameAgentChatNewLease — second GetAsync for same ID returns a different lease wrapping the same AgentChat
    • GetAsync_AfterLastLeaseDisposed_RemovesFromRunningSessions — disposing the only lease removes session from RunningSessions
    • GetAsync_AfterLastLeaseDisposed_DisposesAgentChat — disposing the only lease disposes the underlying AgentChat (verified via disposal-tracking client)
    • GetAsync_MultipleLeases_RemovedOnlyAfterAllDisposed — with two leases outstanding, RunningSessions still has entry after first dispose; removed only after second
    • GetAsync_ConcurrentFirstAcquire_OnlyCreatesOneAgentChat — two concurrent GetAsync calls for the same session only call RestoreAsync once (one AgentChat created)
    • CreateAsync_PersistsSessionBeforeReturning — after CreateAsync, persistence store has the agent definition JSON
    • CreateAsync_NewSession_AppearsInRunningSessions — after CreateAsync, RunningSessions contains the session
    • CreateAsync_DuplicateSessionId_ThrowsInvalidOperationException — second CreateAsync with same ID throws
    • RunningSessions_Add_HappensOnForegroundScheduler — constructed with a capturing scheduler; RunningSessions.Add is invoked on that scheduler
    • RunningSessions_Remove_HappensOnForegroundScheduler — same for RunningSessions.Remove after last lease
    • DisposeAsync_ReleasesAllSessions — factory disposes all outstanding AgentChat instances when DisposeAsync is called
  3. JoshuaRowePhantom commented on Jul 6, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Verification failed

    Checked: Searched \Phantom.Workspaces.Llm.Core\ for \IRunningAgentChatFactory.cs\ and \AgentChatFactory.cs; searched \Phantom.Workspaces.Llm.Interfaces/AgentServices.cs\ for the \RunningAgentChatFactory\ property; searched \Phantom.Workspaces.Llm.Core.Tests\ for \AgentChatFactoryTests.cs. Also searched the entire codebase for the identifiers \IRunningAgentChatFactory, \AgentChatFactory, and \RunningAgentChatFactory.

    Found: \AgentServices.cs\ exists but does not contain \IRunningAgentChatFactory? RunningAgentChatFactory. A pre-existing \RunningAgentChatTable/\IRunningAgentChatTable\ lives in \Phantom.Workspaces\Services\\ (different project, different design — string keys, event-based notification, no \ObservableCollection). No git commits reference this issue number.

    Missing:

    • \Phantom.Workspaces.Llm.Core/IRunningAgentChatFactory.cs\ — interface not created
    • \Phantom.Workspaces.Llm.Core/AgentChatFactory.cs\ — reference-counted implementation not created
    • \Phantom.Workspaces.Llm.Interfaces/AgentServices.cs\ — \IRunningAgentChatFactory? RunningAgentChatFactory\ property not added
    • \Phantom.Workspaces.Llm.Core.Tests/AgentChatFactoryTests.cs\ — all 13 specified test methods not written
  4. added 2 commits that reference this issue on Jul 7, 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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions