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), }; } }