using System.Collections.Concurrent;
using AIStudio.Chat;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Tools.AssistantSessions;
///
/// Keeps assistant sessions alive while their Blazor components are not mounted.
///
/// The message bus used to publish assistant session changes.
public sealed class AssistantSessionService(MessageBus messageBus)
{
///
/// Mutable runtime state owned exclusively by .
///
///
/// This type intentionally exists in addition to .
/// It holds runtime-only data such as the cancellation token source and lock, while
/// snapshots are copied DTOs for UI components and message bus payloads.
///
private sealed class AssistantSessionState
{
///
/// Identifies this concrete run of an assistant session.
///
public required Guid SessionId { get; init; }
///
/// Identifies the assistant and logical session slot this run belongs to.
///
public required AssistantSessionKey Key { get; init; }
///
/// Cancels the active assistant run.
///
public required CancellationTokenSource CancellationTokenSource { get; init; }
///
/// Stores when the session run started.
///
public required DateTimeOffset StartedAt { get; init; }
///
/// Stores when the session state was last changed.
///
public DateTimeOffset UpdatedAt { get; set; }
///
/// Stores when the session reached a terminal state.
///
public DateTimeOffset? FinishedAt { get; set; }
///
/// Stores the user-visible assistant title.
///
public string Title { get; set; } = string.Empty;
///
/// Stores the current lifecycle state of the session.
///
public AssistantSessionStatus Status { get; set; }
///
/// Stores the user-visible error message for failed sessions.
///
public string ErrorMessage { get; set; } = string.Empty;
///
/// Stores the current assistant chat thread, including streamed output.
///
public ChatThread? ChatThread { get; set; }
///
/// Stores the assistant component state captured from the running UI instance.
///
public Dictionary State { get; set; } = new(StringComparer.Ordinal);
///
/// Guards mutable fields while snapshots are created or updates are applied.
///
public readonly Lock SyncRoot = new();
}
///
/// Stores one assistant session per session key.
///
private readonly ConcurrentDictionary sessions = new();
///
/// Gets whether at least one assistant session is still active.
///
public bool HasActiveSessions => this.sessions.Values.Any(session => session.Status is AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING);
///
/// Gets copied snapshots for all known assistant sessions.
///
/// Session snapshots ordered by newest update first.
public IReadOnlyCollection GetSnapshots()
{
return this.sessions.Values
.Select(CreateSnapshot)
.OrderByDescending(snapshot => snapshot.UpdatedAt)
.ToList();
}
///
/// Tries to get the current snapshot for an assistant session key.
///
/// The assistant session key to look up.
/// The current snapshot, or null when no session exists.
public AssistantSessionSnapshot? TryGetSnapshot(AssistantSessionKey key)
{
return this.sessions.TryGetValue(key, out var session) ? CreateSnapshot(session) : null;
}
///
/// Tries to get and remove an inactive assistant session snapshot.
///
///
/// This method intentionally does not publish a change event. It is used when
/// a UI instance consumes a finished session exactly once and should keep the
/// restored result locally until the user leaves or resets the assistant.
///
/// The assistant session key to look up and remove.
/// The removed inactive snapshot, or null when no inactive session exists.
public AssistantSessionSnapshot? TryTakeInactiveSnapshot(AssistantSessionKey key)
{
if (!this.sessions.TryGetValue(key, out var session))
return null;
AssistantSessionSnapshot snapshot;
lock (session.SyncRoot)
{
if (session.Status is AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING)
return null;
snapshot = CreateSnapshotWithoutLock(session);
}
return ((ICollection>)this.sessions).Remove(new(key, session)) ? snapshot : null;
}
///
/// Starts a new assistant session when no active session exists for the key.
///
/// The assistant session key.
/// The user-visible assistant title.
/// The cancellation token source owned by the new runtime session.
/// The current assistant chat thread, if one already exists.
/// The initial assistant component state.
/// The component that initiated the session start.
/// The new session snapshot, or the existing active session snapshot.
public async Task TryBeginAsync(AssistantSessionKey key, string title, CancellationTokenSource cancellationTokenSource, ChatThread? chatThread, Dictionary state, ComponentBase? sendingComponent = null)
{
if (this.sessions.TryGetValue(key, out var existing) && existing.Status is AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING)
return CreateSnapshot(existing);
var now = DateTimeOffset.Now;
var session = new AssistantSessionState
{
SessionId = Guid.NewGuid(),
Key = key,
CancellationTokenSource = cancellationTokenSource,
StartedAt = now,
UpdatedAt = now,
Title = title,
Status = AssistantSessionStatus.RUNNING,
ChatThread = chatThread,
State = state,
};
this.sessions[key] = session;
var snapshot = CreateSnapshot(session);
await this.NotifyChangedAsync(session, sendingComponent);
return snapshot;
}
///
/// Updates a running assistant session with the latest UI and chat state.
///
/// The assistant session key.
/// The concrete run ID that is allowed to write the checkpoint.
/// The current user-visible assistant title.
/// The current assistant chat thread.
/// The current assistant component state.
/// The component that initiated the checkpoint.
public async Task CheckpointAsync(AssistantSessionKey key, Guid sessionId, string title, ChatThread? chatThread, Dictionary state, ComponentBase? sendingComponent = null)
{
if (!this.sessions.TryGetValue(key, out var session))
return;
lock (session.SyncRoot)
{
if (session.SessionId != sessionId)
return;
session.Title = title;
session.ChatThread = chatThread;
session.State = state;
session.UpdatedAt = DateTimeOffset.Now;
}
await this.NotifyChangedAsync(session, sendingComponent);
}
///
/// Requests cancellation for an active assistant session.
///
/// The assistant session key to cancel.
/// The component that initiated the cancellation.
public async Task CancelAsync(AssistantSessionKey key, ComponentBase? sendingComponent = null)
{
if (!this.sessions.TryGetValue(key, out var session))
return;
lock (session.SyncRoot)
{
if (session.Status is not AssistantSessionStatus.RUNNING)
return;
session.Status = AssistantSessionStatus.CANCELING;
session.UpdatedAt = DateTimeOffset.Now;
}
try
{
if (!session.CancellationTokenSource.IsCancellationRequested)
await session.CancellationTokenSource.CancelAsync();
}
catch (ObjectDisposedException)
{
return;
}
await this.NotifyChangedAsync(session, sendingComponent);
}
///
/// Moves an assistant session into a terminal state and publishes completion.
///
/// The assistant session key.
/// The concrete run ID that is allowed to complete the session.
/// The terminal status to store.
/// The user-visible error message for failed sessions.
/// The final assistant chat thread.
/// The final assistant component state.
/// The component that initiated the completion.
public async Task CompleteAsync(AssistantSessionKey key, Guid sessionId, AssistantSessionStatus status, string errorMessage, ChatThread? chatThread, Dictionary state, ComponentBase? sendingComponent = null)
{
if (!this.sessions.TryGetValue(key, out var session))
return;
lock (session.SyncRoot)
{
if (session.SessionId != sessionId)
return;
session.Status = status;
session.ErrorMessage = errorMessage;
session.ChatThread = chatThread;
session.State = state;
session.UpdatedAt = DateTimeOffset.Now;
session.FinishedAt = session.UpdatedAt;
}
await this.NotifyChangedAsync(session, sendingComponent);
await messageBus.SendMessage(sendingComponent, Event.ASSISTANT_SESSION_FINISHED, CreateSnapshot(session));
try
{
session.CancellationTokenSource.Dispose();
}
catch
{
// ignore
}
}
///
/// Clears an inactive assistant session.
///
/// The assistant session key to clear.
public async Task ClearAsync(AssistantSessionKey key)
{
if (!this.sessions.TryGetValue(key, out var session))
return;
if (session.Status is AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING)
return;
lock (session.SyncRoot)
{
session.Status = AssistantSessionStatus.NONE;
session.ChatThread = null;
session.State = new(StringComparer.Ordinal);
session.UpdatedAt = DateTimeOffset.Now;
session.FinishedAt = session.UpdatedAt;
}
this.sessions.TryRemove(key, out _);
await messageBus.SendMessage(null, Event.ASSISTANT_SESSION_CHANGED, CreateSnapshot(session));
}
///
/// Clears all inactive sessions for a component.
///
/// The component whose inactive sessions should be cleared.
public async Task ClearInactiveSessionsForComponentAsync(Components component)
{
var matchingKeys = this.sessions
.Where(pair => pair.Key.Component == component && pair.Value.Status is not AssistantSessionStatus.RUNNING and not AssistantSessionStatus.CANCELING)
.Select(pair => pair.Key)
.ToList();
foreach (var key in matchingKeys)
await this.ClearAsync(key);
}
///
/// Publishes an assistant session change event.
///
/// The runtime session whose copied snapshot should be published.
/// The component that initiated the session change.
private async Task NotifyChangedAsync(AssistantSessionState session, ComponentBase? sendingComponent = null)
{
await messageBus.SendMessage(sendingComponent, Event.ASSISTANT_SESSION_CHANGED, CreateSnapshot(session));
}
///
/// Creates a copied, external snapshot from the internal runtime state.
///
/// The runtime session to copy.
/// A snapshot safe to send to UI components.
private static AssistantSessionSnapshot CreateSnapshot(AssistantSessionState session)
{
lock (session.SyncRoot)
{
return CreateSnapshotWithoutLock(session);
}
}
///
/// Creates a copied, external snapshot while the caller already holds the session lock.
///
/// The runtime session to copy.
/// A snapshot safe to send to UI components.
private static AssistantSessionSnapshot CreateSnapshotWithoutLock(AssistantSessionState session)
{
return new()
{
SessionId = session.SessionId,
Key = session.Key,
Title = session.Title,
Status = session.Status,
StartedAt = session.StartedAt,
UpdatedAt = session.UpdatedAt,
FinishedAt = session.FinishedAt,
ErrorMessage = session.ErrorMessage,
ChatThread = session.ChatThread,
State = new Dictionary(session.State, StringComparer.Ordinal),
};
}
}