From e1a6621a4eb74212d10f74044c5568ab76ca1a63 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 4 May 2026 08:59:24 -0700 Subject: [PATCH 1/2] Fix Post-session Processing --- .../Chat/IAIChatSessionStore.cs | 52 +++ .../Models/PostSessionTask.cs | 15 - .../AIChatSessionCloseCycleService.cs | 387 ++++++++---------- .../Services/AIChatSessionCloseRunner.cs | 12 +- .../Services/PostSessionProcessingService.cs | 169 +++++--- .../AIDataSourceAlignmentBackgroundService.cs | 11 +- .../AIDataSourceIndexingBackgroundService.cs | 11 +- .../Prompts/post-session-analysis-prompt.md | 2 +- .../Prompts/post-session-analysis.md | 11 +- .../AI/Services/AIProfileDocumentService.cs | 25 +- ...AIChatDocumentIndexingBackgroundService.cs | 9 +- .../Pages/AI/AIProfiles/Create.razor | 77 +--- .../Components/Pages/AI/AIProfiles/Edit.razor | 77 +--- .../ViewModels/AIProfileViewModel.cs | 12 - .../ViewModels/AITemplateViewModel.cs | 6 - .../Areas/AI/ViewModels/AIProfileViewModel.cs | 12 - .../AI/ViewModels/AITemplateViewModel.cs | 6 - .../Areas/AI/Views/AIProfile/Create.cshtml | 14 +- .../Areas/AI/Views/AIProfile/Edit.cshtml | 64 +-- .../Areas/AI/Views/AITemplate/Create.cshtml | 14 +- .../Areas/AI/Views/AITemplate/Edit.cshtml | 64 +-- ...AIChatDocumentIndexingBackgroundService.cs | 9 +- src/Startup/CrestApps.Core.Mvc.Web/Program.cs | 7 + .../ServiceCollectionExtensions.cs | 1 + .../Services/EntityCoreAIChatSessionStore.cs | 152 +++++++ .../ServiceCollectionExtensions.cs | 1 + .../Services/YesSqlAIChatSessionStore.cs | 141 +++++++ ...ndSessionNotificationActionHandlerTests.cs | 4 +- .../AIChatSessionPostCloseProcessorTests.cs | 4 +- .../PostSessionProcessingServiceTests.cs | 94 +++-- .../Framework/Mvc/AIChatHubCoreTests.cs | 2 +- 31 files changed, 751 insertions(+), 714 deletions(-) create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionStore.cs create mode 100644 src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionStore.cs create mode 100644 src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionStore.cs diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionStore.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionStore.cs new file mode 100644 index 00000000..3a10f45c --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionStore.cs @@ -0,0 +1,52 @@ +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Chat; + +/// +/// Provides unscoped persistence access to AI chat sessions. +/// Unlike which may apply user-scoping or +/// business rules, this store offers direct data access suitable for background +/// processing, administrative tasks, and system-level operations. +/// +public interface IAIChatSessionStore +{ + /// + /// Asynchronously retrieves a chat session by its unique session identifier + /// without applying user-scoping or ownership checks. + /// + /// The unique identifier of the chat session. + /// The token to monitor for cancellation requests. + /// The matching session, or if not found. + Task FindByIdAsync(string sessionId, CancellationToken cancellationToken = default); + + /// + /// Asynchronously retrieves all active sessions for the specified profile that have + /// been inactive since before the given cutoff time. + /// + /// The profile identifier to filter sessions by. + /// The UTC cutoff time; sessions with last activity before this are returned. + /// The token to monitor for cancellation requests. + /// A read-only list of inactive active sessions. + Task> GetInactiveActiveSessionsAsync( + string profileId, + DateTime cutoffUtc, + CancellationToken cancellationToken = default); + + /// + /// Asynchronously retrieves all closed or abandoned sessions for the specified profile + /// that may require post-close processing. + /// + /// The profile identifier to filter sessions by. + /// The token to monitor for cancellation requests. + /// A read-only list of closed or abandoned sessions. + Task> GetClosedSessionsAsync( + string profileId, + CancellationToken cancellationToken = default); + + /// + /// Asynchronously persists the specified chat session. + /// + /// The chat session to save. + /// The token to monitor for cancellation requests. + Task SaveAsync(AIChatSession chatSession, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs index 9563a571..332fcc63 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs @@ -40,19 +40,4 @@ public sealed class PostSessionTask /// Gets or sets the AI tool names available to this task during post-session processing. /// public string[] ToolNames { get; set; } = []; - - /// - /// Gets or sets the AI agent profile names available to this task during post-session processing. - /// - public string[] AgentNames { get; set; } = []; - - /// - /// Gets or sets the A2A connection identifiers available to this task during post-session processing. - /// - public string[] A2AConnectionIds { get; set; } = []; - - /// - /// Gets or sets the MCP connection identifiers available to this task during post-session processing. - /// - public string[] McpConnectionIds { get; set; } = []; } diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseCycleService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseCycleService.cs index 23619c9c..ff771eb1 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseCycleService.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionCloseCycleService.cs @@ -10,29 +10,26 @@ namespace CrestApps.Core.AI.Chat.Services; /// /// Runs a single shared cycle that closes inactive AI chat sessions and retries pending post-close work. /// Hosts can call this service directly when they need the framework logic without the default hosted runner. +/// Uses for unscoped data access, ensuring correct behavior +/// in background processing contexts where no user/HTTP context is available. /// public sealed class AIChatSessionCloseCycleService { private static readonly TimeSpan _defaultInactivityTimeout = TimeSpan.FromMinutes(30); private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(5); - private const int _pageSize = 100; - private readonly IServiceScopeFactory _scopeFactory; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// - /// The service scope factory. /// The time provider. /// The logger. public AIChatSessionCloseCycleService( - IServiceScopeFactory scopeFactory, TimeProvider timeProvider, ILogger logger) { - _scopeFactory = scopeFactory; _timeProvider = timeProvider; _logger = logger; } @@ -40,83 +37,66 @@ public AIChatSessionCloseCycleService( /// /// Runs one AI chat session close cycle immediately. /// + /// The root service provider used to create short-lived scopes per work unit. /// The cancellation token. - public async Task RunOnceAsync(CancellationToken cancellationToken = default) + public async Task RunOnceAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken = default) { try { - await using var scope = _scopeFactory.CreateAsyncScope(); - var sessionManager = scope.ServiceProvider.GetRequiredService(); - var profileManager = scope.ServiceProvider.GetRequiredService(); - var postCloseProcessor = scope.ServiceProvider.GetRequiredService(); - var promptStore = scope.ServiceProvider.GetRequiredService(); - var storeCommitter = scope.ServiceProvider.GetRequiredService(); var utcNow = _timeProvider.GetUtcNow().UtcDateTime; - var profiles = (await profileManager.GetAsync(AIProfileType.Chat, cancellationToken)).ToList(); + + List workItems; + + using (var discoveryScope = serviceProvider.CreateScope()) + { + workItems = await DiscoverWorkAsync(discoveryScope.ServiceProvider, utcNow, cancellationToken); + } + + if (workItems.Count == 0) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("AI chat session close cycle completed with no work."); + } + + return; + } + var closedCount = 0; var abandonedCount = 0; var retriedCount = 0; var recoveredCount = 0; var failedCount = 0; - foreach (var profile in profiles) + foreach (var workItem in workItems) { if (cancellationToken.IsCancellationRequested) { break; } - var entries = await ListSessionEntriesAsync(sessionManager, profile.ItemId, cancellationToken); - var (profileClosedCount, profileAbandonedCount) = await CloseInactiveSessionsAsync( - sessionManager, - promptStore, - postCloseProcessor, - profile, - entries, - utcNow, - cancellationToken); - - closedCount += profileClosedCount; - abandonedCount += profileAbandonedCount; - - var (profileRetriedCount, profileRecoveredCount, profileFailedCount) = await RetryPendingProcessingAsync( - sessionManager, - promptStore, - postCloseProcessor, - profile, - entries, - utcNow, - cancellationToken); - - retriedCount += profileRetriedCount; - recoveredCount += profileRecoveredCount; - failedCount += profileFailedCount; - } + using var processScope = serviceProvider.CreateScope(); - await storeCommitter.CommitAsync(cancellationToken); + var result = await ProcessWorkItemAsync(processScope.ServiceProvider, workItem, utcNow, cancellationToken); - if ((closedCount > 0 - || abandonedCount > 0 - || retriedCount > 0 - || recoveredCount > 0 - || failedCount > 0) - && _logger.IsEnabled(LogLevel.Information)) + closedCount += result.ClosedCount; + abandonedCount += result.AbandonedCount; + retriedCount += result.RetriedCount; + recoveredCount += result.RecoveredCount; + failedCount += result.FailedCount; + } + + if (_logger.IsEnabled(LogLevel.Information)) { _logger.LogInformation( - "AI chat session close cycle completed. Profiles={ProfileCount}, Closed={ClosedCount}, Abandoned={AbandonedCount}, Retried={RetriedCount}, Recovered={RecoveredCount}, Failed={FailedCount}.", - profiles.Count, + "AI chat session close cycle completed. Sessions={SessionCount}, Closed={ClosedCount}, Abandoned={AbandonedCount}, Retried={RetriedCount}, Recovered={RecoveredCount}, Failed={FailedCount}.", + workItems.Count, closedCount, abandonedCount, retriedCount, recoveredCount, failedCount); } - else if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "AI chat session close cycle completed with no work. Profiles evaluated: {ProfileCount}.", - profiles.Count); - } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -128,211 +108,178 @@ public async Task RunOnceAsync(CancellationToken cancellationToken = default) } } - private static async Task> ListSessionEntriesAsync( - IAIChatSessionManager sessionManager, - string profileId, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(sessionManager); - ArgumentException.ThrowIfNullOrEmpty(profileId); - - var entries = new List(); - var queryContext = new AIChatSessionQueryContext - { - ProfileId = profileId, - }; - var page = 1; - - while (true) - { - var result = await sessionManager.PageAsync(page, _pageSize, queryContext, cancellationToken); - var pageEntries = result.Sessions.ToList(); - - if (pageEntries.Count == 0) - { - break; - } - - entries.AddRange(pageEntries); - page++; - } - - return entries; - } - - private async Task<(int ClosedCount, int AbandonedCount)> CloseInactiveSessionsAsync( - IAIChatSessionManager sessionManager, - IAIChatSessionPromptStore promptStore, - AIChatSessionPostCloseProcessor postCloseProcessor, - AIProfile profile, - IReadOnlyList entries, + private static async Task> DiscoverWorkAsync( + IServiceProvider serviceProvider, DateTime utcNow, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(sessionManager); - ArgumentNullException.ThrowIfNull(promptStore); - ArgumentNullException.ThrowIfNull(postCloseProcessor); - ArgumentNullException.ThrowIfNull(profile); - ArgumentNullException.ThrowIfNull(entries); - - var settings = profile.GetOrCreateSettings(); - var timeout = settings?.SessionInactivityTimeoutInMinutes > 0 - ? TimeSpan.FromMinutes(settings.SessionInactivityTimeoutInMinutes) - : _defaultInactivityTimeout; - var cutoffUtc = utcNow - timeout; - var closedCount = 0; - var abandonedCount = 0; - - foreach (var entry in entries) + var sessionStore = serviceProvider.GetRequiredService(); + var profileManager = serviceProvider.GetRequiredService(); + var postCloseProcessor = serviceProvider.GetRequiredService(); + var profiles = await profileManager.GetAsync(AIProfileType.Chat, cancellationToken); + var workItems = new List(); + + foreach (var profile in profiles) { if (cancellationToken.IsCancellationRequested) { break; } - if (entry.Status != ChatSessionStatus.Active || entry.LastActivityUtc >= cutoffUtc) - { - continue; - } + var settings = profile.GetOrCreateSettings(); + var timeout = settings?.SessionInactivityTimeoutInMinutes > 0 + ? TimeSpan.FromMinutes(settings.SessionInactivityTimeoutInMinutes) + : _defaultInactivityTimeout; + var cutoffUtc = utcNow - timeout; - var chatSession = await sessionManager.FindByIdAsync(entry.SessionId, cancellationToken); + var inactiveSessions = await sessionStore.GetInactiveActiveSessionsAsync(profile.ItemId, cutoffUtc, cancellationToken); - if (chatSession is null || chatSession.Status != ChatSessionStatus.Active) + foreach (var session in inactiveSessions) { - continue; + workItems.Add(new SessionWorkItem(profile.ItemId, session.SessionId, SessionWorkType.Close)); } - var prompts = await promptStore.GetPromptsAsync(chatSession.SessionId); - chatSession.Status = DetermineInactiveSessionStatus(prompts); - chatSession.ClosedAtUtc = utcNow; + var closedSessions = await sessionStore.GetClosedSessionsAsync(profile.ItemId, cancellationToken); - if (postCloseProcessor.QueueIfNeeded(profile, chatSession)) + foreach (var session in closedSessions) { - await postCloseProcessor.ProcessAsync(profile, chatSession, prompts, cancellationToken); - } - else - { - chatSession.PostSessionProcessingStatus = PostSessionProcessingStatus.None; - } + if (!postCloseProcessor.NeedsProcessing(profile, session)) + { + continue; + } - await sessionManager.SaveAsync(chatSession, cancellationToken); + if (session.PostSessionProcessingAttempts >= postCloseProcessor.MaxPostCloseAttempts) + { + workItems.Add(new SessionWorkItem(profile.ItemId, session.SessionId, SessionWorkType.MarkFailed)); - if (chatSession.Status == ChatSessionStatus.Closed) - { - closedCount++; - } - else - { - abandonedCount++; - } + continue; + } - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Finalized inactive session '{SessionId}' for profile '{ProfileId}' as '{Status}'. Post-processing: {NeedsProcessing}.", - chatSession.SessionId, - profile.ItemId, - chatSession.Status, - chatSession.PostSessionProcessingStatus != PostSessionProcessingStatus.None); + if (session.PostSessionProcessingLastAttemptUtc.HasValue + && (utcNow - session.PostSessionProcessingLastAttemptUtc.Value) < _retryDelay) + { + continue; + } + + workItems.Add(new SessionWorkItem(profile.ItemId, session.SessionId, SessionWorkType.Retry)); } } - return (closedCount, abandonedCount); + return workItems; } - private async Task<(int RetriedCount, int RecoveredCount, int FailedCount)> RetryPendingProcessingAsync( - IAIChatSessionManager sessionManager, - IAIChatSessionPromptStore promptStore, - AIChatSessionPostCloseProcessor postCloseProcessor, - AIProfile profile, - IReadOnlyList entries, + private async Task ProcessWorkItemAsync( + IServiceProvider serviceProvider, + SessionWorkItem workItem, DateTime utcNow, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(sessionManager); - ArgumentNullException.ThrowIfNull(promptStore); - ArgumentNullException.ThrowIfNull(postCloseProcessor); - ArgumentNullException.ThrowIfNull(profile); - ArgumentNullException.ThrowIfNull(entries); + var sessionStore = serviceProvider.GetRequiredService(); + var profileManager = serviceProvider.GetRequiredService(); + var postCloseProcessor = serviceProvider.GetRequiredService(); + var promptStore = serviceProvider.GetRequiredService(); + var storeCommitter = serviceProvider.GetRequiredService(); - var retriedCount = 0; - var recoveredCount = 0; - var failedCount = 0; + var profile = await profileManager.FindByIdAsync(workItem.ProfileId, cancellationToken); - foreach (var entry in entries) + if (profile is null) { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - if (entry.Status != ChatSessionStatus.Closed && entry.Status != ChatSessionStatus.Abandoned) - { - continue; - } + return default; + } - var chatSession = await sessionManager.FindByIdAsync(entry.SessionId, cancellationToken); + var chatSession = await sessionStore.FindByIdAsync(workItem.SessionId, cancellationToken); - if (chatSession is null) - { - continue; - } + if (chatSession is null) + { + return default; + } - var originalStatus = chatSession.PostSessionProcessingStatus; - var needsQueuedProcessing = postCloseProcessor.QueueIfNeeded(profile, chatSession); + var result = new WorkItemResult(); - if (!needsQueuedProcessing) - { - continue; - } - - if (originalStatus != PostSessionProcessingStatus.Pending) - { - recoveredCount++; + switch (workItem.WorkType) + { + case SessionWorkType.Close: + await ProcessCloseAsync(sessionStore, promptStore, postCloseProcessor, profile, chatSession, utcNow, cancellationToken); + result.ClosedCount = chatSession.Status == ChatSessionStatus.Closed ? 1 : 0; + result.AbandonedCount = chatSession.Status == ChatSessionStatus.Abandoned ? 1 : 0; + break; - if (_logger.IsEnabled(LogLevel.Information)) - { - _logger.LogInformation( - "Recovered closed session '{SessionId}' for post-close processing. Previous processing status was '{PreviousStatus}'.", - chatSession.SessionId, - originalStatus); - } - } + case SessionWorkType.Retry: + await ProcessRetryAsync(sessionStore, promptStore, postCloseProcessor, profile, chatSession, cancellationToken); + result.RetriedCount = 1; + result.RecoveredCount = chatSession.PostSessionProcessingStatus == PostSessionProcessingStatus.Completed ? 1 : 0; + break; - if (chatSession.PostSessionProcessingAttempts >= postCloseProcessor.MaxPostCloseAttempts) - { + case SessionWorkType.MarkFailed: chatSession.PostSessionProcessingStatus = PostSessionProcessingStatus.Failed; - await sessionManager.SaveAsync(chatSession, cancellationToken); - failedCount++; + await sessionStore.SaveAsync(chatSession, cancellationToken); + result.FailedCount = 1; _logger.LogWarning( "Post-session processing for session '{SessionId}' failed after {MaxAttempts} attempts.", chatSession.SessionId, postCloseProcessor.MaxPostCloseAttempts); + break; + } - continue; - } + await storeCommitter.CommitAsync(cancellationToken); - if (chatSession.PostSessionProcessingLastAttemptUtc.HasValue - && (utcNow - chatSession.PostSessionProcessingLastAttemptUtc.Value) < _retryDelay) - { - continue; - } + return result; + } + + private async Task ProcessCloseAsync( + IAIChatSessionStore sessionStore, + IAIChatSessionPromptStore promptStore, + AIChatSessionPostCloseProcessor postCloseProcessor, + AIProfile profile, + AIChatSession chatSession, + DateTime utcNow, + CancellationToken cancellationToken) + { + var prompts = await promptStore.GetPromptsAsync(chatSession.SessionId); + chatSession.Status = DetermineInactiveSessionStatus(prompts); + chatSession.ClosedAtUtc = utcNow; - var prompts = await promptStore.GetPromptsAsync(chatSession.SessionId); + if (postCloseProcessor.QueueIfNeeded(profile, chatSession)) + { await postCloseProcessor.ProcessAsync(profile, chatSession, prompts, cancellationToken); - await sessionManager.SaveAsync(chatSession, cancellationToken); - retriedCount++; + } + else + { + chatSession.PostSessionProcessingStatus = PostSessionProcessingStatus.None; + } - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Processed pending post-close work for session '{SessionId}'.", - chatSession.SessionId); - } + await sessionStore.SaveAsync(chatSession, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Finalized inactive session '{SessionId}' for profile '{ProfileId}' as '{Status}'. Post-processing: {NeedsProcessing}.", + chatSession.SessionId, + profile.ItemId, + chatSession.Status, + chatSession.PostSessionProcessingStatus != PostSessionProcessingStatus.None); } + } - return (retriedCount, recoveredCount, failedCount); + private async Task ProcessRetryAsync( + IAIChatSessionStore sessionStore, + IAIChatSessionPromptStore promptStore, + AIChatSessionPostCloseProcessor postCloseProcessor, + AIProfile profile, + AIChatSession chatSession, + CancellationToken cancellationToken) + { + var prompts = await promptStore.GetPromptsAsync(chatSession.SessionId); + await postCloseProcessor.ProcessAsync(profile, chatSession, prompts, cancellationToken); + await sessionStore.SaveAsync(chatSession, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Processed pending post-close work for session '{SessionId}'.", + chatSession.SessionId); + } } private static ChatSessionStatus DetermineInactiveSessionStatus(IReadOnlyList prompts) @@ -343,4 +290,22 @@ private static ChatSessionStatus DetermineInactiveSessionStatus(IReadOnlyList _logger; private readonly Lock _syncLock = new(); @@ -22,12 +23,15 @@ public sealed class AIChatSessionCloseRunner /// Initializes a new instance of the class. /// /// The shared cycle service. + /// The service provider. /// The logger. public AIChatSessionCloseRunner( AIChatSessionCloseCycleService cycleService, + IServiceProvider serviceProvider, ILogger logger) { _cycleService = cycleService; + _serviceProvider = serviceProvider; _logger = logger; } @@ -104,20 +108,20 @@ public async Task StopAsync(CancellationToken cancellationToken = default) /// Runs the shared AI chat session close cycle immediately. /// /// The cancellation token. - public Task RunOnceAsync(CancellationToken cancellationToken = default) + public async Task RunOnceAsync(CancellationToken cancellationToken = default) { - return _cycleService.RunOnceAsync(cancellationToken); + await _cycleService.RunOnceAsync(_serviceProvider, cancellationToken); } private async Task RunAsync(CancellationToken stoppingToken) { - await _cycleService.RunOnceAsync(stoppingToken); + await _cycleService.RunOnceAsync(_serviceProvider, stoppingToken); using var timer = new PeriodicTimer(_interval); while (await timer.WaitForNextTickAsync(stoppingToken)) { - await _cycleService.RunOnceAsync(stoppingToken); + await _cycleService.RunOnceAsync(_serviceProvider, stoppingToken); } } } diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs index 90956c0e..e4a953eb 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs @@ -4,6 +4,7 @@ using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Services; +using CrestApps.Core.AI.Tooling; using CrestApps.Core.Support.Json; using CrestApps.Core.Templates.Parsing; using CrestApps.Core.Templates.Services; @@ -21,7 +22,7 @@ public sealed class PostSessionProcessingService { private readonly IAIClientFactory _clientFactory; private readonly IAIDeploymentManager _deploymentManager; - private readonly IAIToolsService _toolsService; + private readonly IToolRegistry _toolRegistry; private readonly ITemplateService _aiTemplateService; private readonly ITemplateParser _markdownTemplateParser; private readonly IServiceProvider _serviceProvider; @@ -35,7 +36,7 @@ public sealed class PostSessionProcessingService /// Initializes a new instance of the class. /// /// The client factory. - /// The tools service. + /// The tool registry. /// The ai template service. /// The registered template parsers. /// The default options. @@ -45,7 +46,7 @@ public sealed class PostSessionProcessingService /// The deployment manager. public PostSessionProcessingService( IAIClientFactory clientFactory, - IAIToolsService toolsService, + IToolRegistry toolRegistry, ITemplateService aiTemplateService, IEnumerable templateParsers, DefaultAIOptions defaultOptions, @@ -56,7 +57,7 @@ public PostSessionProcessingService( { _clientFactory = clientFactory; _deploymentManager = deploymentManager; - _toolsService = toolsService; + _toolRegistry = toolRegistry; _aiTemplateService = aiTemplateService; _markdownTemplateParser = ResolveMarkdownTemplateParser(templateParsers); _serviceProvider = serviceProvider; @@ -338,9 +339,7 @@ public async Task> ProcessAsync( new(ChatRole.User, prompt), }; - var toolNames = GetConfiguredToolNames(settings.ToolNames, tasksToProcess); - - var tools = await ResolveToolsAsync(session.SessionId, toolNames); + var tools = await ResolveToolsAsync(session.SessionId, settings.ToolNames, tasksToProcess); // When tools are configured (e.g., sendEmail), use non-generic GetResponseAsync // to allow tool execution. The generic version uses structured output which @@ -348,9 +347,9 @@ public async Task> ProcessAsync( // to produce structured JSON output. if (tools is not null && tools.Count > 0) { - if (_logger.IsEnabled(LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Information)) { - _logger.LogDebug( + _logger.LogInformation( "Post-session processing for session '{SessionId}' using tools path with {ToolCount} tool(s): [{ToolNames}].", session.SessionId, tools.Count, @@ -360,9 +359,9 @@ public async Task> ProcessAsync( return await ProcessWithToolsAsync(session, chatClient, messages, tools, tasksToProcess, cancellationToken); } - if (_logger.IsEnabled(LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Information)) { - _logger.LogDebug( + _logger.LogInformation( "Post-session processing for session '{SessionId}' using structured output path (no tools configured or resolved).", session.SessionId); } @@ -415,15 +414,18 @@ private async Task> ProcessWithToolsAsync( .Count() ?? 0; // Log tool invocation details from the response messages. - if (_logger.IsEnabled(LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Information)) { - _logger.LogDebug( + _logger.LogInformation( "Post-session tools response for session '{SessionId}': MessageCount={MessageCount}, ToolCalls={ToolCallCount}, ToolResults={ToolResultCount}.", session.SessionId, response.Messages?.Count ?? 0, toolCallCount, toolResultCount); + } + if (_logger.IsEnabled(LogLevel.Debug)) + { LogResponseMessages(session.SessionId, "tools", response.Messages); } @@ -1125,39 +1127,6 @@ private static string CreateTaskResultSummary(IEnumerable return string.Join("; ", summaries); } - private static string[] GetConfiguredToolNames( - IEnumerable profileToolNames, - IEnumerable tasks) - { - var configuredNames = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (profileToolNames != null) - { - foreach (var toolName in profileToolNames) - { - if (!string.IsNullOrWhiteSpace(toolName)) - { - configuredNames.Add(toolName); - } - } - } - - if (tasks != null) - { - foreach (var toolName in tasks - .Where(task => task?.ToolNames != null) - .SelectMany(task => task.ToolNames)) - { - if (!string.IsNullOrWhiteSpace(toolName)) - { - configuredNames.Add(toolName); - } - } - } - - return configuredNames.Count > 0 ? [.. configuredNames] : []; - } - private Dictionary ApplyResults( List tasks, List results) @@ -1271,13 +1240,18 @@ private async Task GetChatClientAsync(AIProfile profile) return null; } - private async Task> ResolveToolsAsync(string sessionId, string[] toolNames) + private async Task> ResolveToolsAsync( + string sessionId, + string[] profileToolNames, + List tasks) { + var toolNames = CollectToolNames(profileToolNames, tasks); + if (toolNames is null || toolNames.Length == 0) { - if (_logger.IsEnabled(LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Information)) { - _logger.LogDebug( + _logger.LogInformation( "No tool names configured for post-session processing of session '{SessionId}'.", sessionId); } @@ -1285,37 +1259,114 @@ private async Task> ResolveToolsAsync(string sessionId, string[] t return null; } - if (_logger.IsEnabled(LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Information)) { - _logger.LogDebug( + _logger.LogInformation( "Resolving {ToolCount} tool(s) for post-session processing of session '{SessionId}': [{ToolNames}].", toolNames.Length, sessionId, string.Join(", ", toolNames)); } - var tools = new List(); + var completionContext = new AICompletionContext + { + ToolNames = toolNames, + }; + + var entries = await _toolRegistry.GetAllAsync(completionContext); - foreach (var name in toolNames) + if (entries.Count == 0) { - var tool = await _toolsService.GetByNameAsync(name); + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning( + "Tool registry returned no entries for post-session processing of session '{SessionId}'. Requested tool names: [{ToolNames}].", + sessionId, + string.Join(", ", toolNames)); + } + + return null; + } + + var tools = new List(); - if (tool is not null) + foreach (var entry in entries) + { + try { - tools.Add(tool); + var tool = await entry.CreateAsync(_serviceProvider); + + if (tool is not null) + { + tools.Add(tool); + } + else + { + _logger.LogWarning( + "Post-session tool '{ToolName}' could not be created for session '{SessionId}'.", + entry.Name, + sessionId); + } } - else + catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning( - "Post-session tool '{ToolName}' could not be resolved for session '{SessionId}'. Ensure the tool is registered and its feature is enabled.", - name, + ex, + "Post-session tool '{ToolName}' failed to create for session '{SessionId}'.", + entry.Name, sessionId); } } + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Resolved {ToolCount} tool(s) for post-session processing of session '{SessionId}': [{ToolNames}].", + tools.Count, + sessionId, + string.Join(", ", tools.Select(t => t.Name))); + } + return tools.Count > 0 ? tools : null; } + private static string[] CollectToolNames( + string[] profileToolNames, + List tasks) + { + var toolNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (profileToolNames is not null) + { + foreach (var name in profileToolNames) + { + if (!string.IsNullOrWhiteSpace(name)) + { + toolNames.Add(name); + } + } + } + + if (tasks is not null) + { + foreach (var task in tasks) + { + if (task.ToolNames is not null) + { + foreach (var name in task.ToolNames) + { + if (!string.IsNullOrWhiteSpace(name)) + { + toolNames.Add(name); + } + } + } + } + } + + return toolNames.Count > 0 ? [.. toolNames] : []; + } + private async Task RenderTranscriptAsync( string templateId, IReadOnlyList prompts, diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs b/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs index bf7c49ea..1b794e3c 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs @@ -9,7 +9,7 @@ internal sealed class AIDataSourceAlignmentBackgroundService : BackgroundService { private static readonly TimeSpan _alignmentCheckInterval = TimeSpan.FromMinutes(30); - private readonly IServiceScopeFactory _scopeFactory; + private readonly IServiceProvider _serviceProvider; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; @@ -18,15 +18,15 @@ internal sealed class AIDataSourceAlignmentBackgroundService : BackgroundService /// /// Initializes a new instance of the class. /// - /// The scope factory. + /// The scope factory. /// The time provider. /// The logger. public AIDataSourceAlignmentBackgroundService( - IServiceScopeFactory scopeFactory, + IServiceProvider serviceProvider, TimeProvider timeProvider, ILogger logger) { - _scopeFactory = scopeFactory; + _serviceProvider = serviceProvider; _timeProvider = timeProvider; _logger = logger; } @@ -66,8 +66,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _logger.LogTrace("Starting scheduled AI data-source alignment for UTC date {RunDateUtc}.", runDateUtc); } - await using var scope = _scopeFactory.CreateAsyncScope(); - await AlignDataSourcesAsync(scope.ServiceProvider, stoppingToken); + await AlignDataSourcesAsync(_serviceProvider, stoppingToken); _lastRunDateUtc = runDateUtc; } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceIndexingBackgroundService.cs b/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceIndexingBackgroundService.cs index 18f92087..3d9a09f8 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceIndexingBackgroundService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceIndexingBackgroundService.cs @@ -7,22 +7,22 @@ namespace CrestApps.Core.AI.Services; internal sealed class AIDataSourceIndexingBackgroundService : BackgroundService { private readonly AIDataSourceIndexingQueue _queue; - private readonly IServiceScopeFactory _scopeFactory; + private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The queue. - /// The scope factory. + /// The scope factory. /// The logger. public AIDataSourceIndexingBackgroundService( AIDataSourceIndexingQueue queue, - IServiceScopeFactory scopeFactory, + IServiceProvider serviceProvider, ILogger logger) { _queue = queue; - _scopeFactory = scopeFactory; + _serviceProvider = serviceProvider; _logger = logger; } @@ -41,8 +41,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _logger.LogTrace("Dequeued data-source work item {WorkItemType}. DataSourceId={DataSourceId}, SourceIndexProfileName={SourceIndexProfileName}, DocumentCount={DocumentCount}.", workItem.Type, workItem.DataSource?.ItemId, workItem.SourceIndexProfileName, workItem.DocumentIds.Count); } - await using var scope = _scopeFactory.CreateAsyncScope(); - var indexingService = scope.ServiceProvider.GetRequiredService(); + var indexingService = _serviceProvider.GetRequiredService(); switch (workItem.Type) { diff --git a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis-prompt.md b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis-prompt.md index 91a7e40f..c94b13f2 100644 --- a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis-prompt.md +++ b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis-prompt.md @@ -10,7 +10,7 @@ Parameters: Analyze the following completed chat conversation and produce results for the requested tasks. Return exactly one structured result for each task listed below. Do not omit tasks, and do not return an empty tasks array. If a task does not need a tool call, still return its result value. -IMPORTANT: If you call any tools, you MUST still return the JSON output with the "tasks" array as your final response after all tool calls complete. +IMPORTANT: If a task's instructions tell you to call a tool (e.g., sendEmail), you MUST call that tool. Execute all required tool calls FIRST, then return the JSON output with the "tasks" array as your final response. Tasks to process: {% for task in tasks %} diff --git a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis.md b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis.md index ae6a8750..0d50279c 100644 --- a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis.md +++ b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/post-session-analysis.md @@ -11,11 +11,12 @@ You are a post-session analysis assistant. Your job is to analyze a completed ch 1. Analyze the ENTIRE conversation transcript provided. 2. For PredefinedOptions tasks: select the best matching option(s) from the provided list. Use the option descriptions to guide your selection. If "allowMultiple" is true, you may select more than one option separated by commas. If false, select exactly one. 3. For Semantic tasks: follow the provided instructions and produce a freeform text result. -4. Return ONLY valid JSON only. Do NOT wrap the response in markdown code fences (```). No explanations, no comments. -5. Return exactly one result for every requested task, using the same task name. -6. Never return an empty "tasks" array. If a task does not require a tool call, still return the task result value. -7. Only return tasks that were requested. -8. If you are given tools and you call them, you MUST still produce the JSON output below AFTER all tool calls have completed. Tool execution does not replace the required JSON response. Your final message MUST always be the JSON output. +4. When tools are available and a task's instructions reference calling a tool, you MUST call that tool as part of processing the task. Do NOT skip tool calls that the task instructions require. Execute all required tool calls BEFORE producing your final response. +5. Return ONLY valid JSON only. Do NOT wrap the response in markdown code fences (```). No explanations, no comments. +6. Return exactly one result for every requested task, using the same task name. +7. Never return an empty "tasks" array. If a task does not require a tool call, still return the task result value. +8. Only return tasks that were requested. +9. After all tool calls have completed, you MUST still produce the JSON output below as your final message. Tool execution does not replace the required JSON response. [Output Format] { diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs index b13f2b74..c1e47a75 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs @@ -16,19 +16,19 @@ namespace CrestApps.Core.Blazor.Web.Areas.AI.Services; /// public sealed class AIProfileDocumentService { - private readonly IServiceScopeFactory _scopeFactory; + private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// - /// The service scope factory used to create isolated DI scopes. + /// The service scope factory used to create isolated DI scopes. /// The logger instance. public AIProfileDocumentService( - IServiceScopeFactory scopeFactory, + IServiceProvider serviceProvider, ILogger logger) { - _scopeFactory = scopeFactory; + _serviceProvider = serviceProvider; _logger = logger; } @@ -43,14 +43,13 @@ public async Task UploadDocumentsAsync(AIProfile profile, IReadOnlyCollection(); - var chunkStore = scope.ServiceProvider.GetRequiredService(); - var fileStore = scope.ServiceProvider.GetRequiredService(); - var documentProcessingService = scope.ServiceProvider.GetRequiredService(); - var deploymentManager = scope.ServiceProvider.GetRequiredService(); - var aiClientFactory = scope.ServiceProvider.GetRequiredService(); - var documentIndexingService = scope.ServiceProvider.GetRequiredService(); + var documentStore = _serviceProvider.GetRequiredService(); + var chunkStore = _serviceProvider.GetRequiredService(); + var fileStore = _serviceProvider.GetRequiredService(); + var documentProcessingService = _serviceProvider.GetRequiredService(); + var deploymentManager = _serviceProvider.GetRequiredService(); + var aiClientFactory = _serviceProvider.GetRequiredService(); + var documentIndexingService = _serviceProvider.GetRequiredService(); var embeddingGenerator = await CreateEmbeddingGeneratorAsync(profile, deploymentManager, aiClientFactory); @@ -129,7 +128,7 @@ public async Task RemoveDocumentsAsync(AIProfile profile, IReadOnlyCollection(); var chunkStore = scope.ServiceProvider.GetRequiredService(); var fileStore = scope.ServiceProvider.GetRequiredService(); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs index 7eacd167..7065014f 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs @@ -6,16 +6,16 @@ namespace CrestApps.Core.Blazor.Web.Areas.AIChat.BackgroundServices; public sealed class AIChatDocumentIndexingBackgroundService : BackgroundService { private readonly SampleAIChatDocumentIndexingQueue _queue; - private readonly IServiceScopeFactory _scopeFactory; + private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; public AIChatDocumentIndexingBackgroundService( SampleAIChatDocumentIndexingQueue queue, - IServiceScopeFactory scopeFactory, + IServiceProvider serviceProvider, ILogger logger) { _queue = queue; - _scopeFactory = scopeFactory; + _serviceProvider = serviceProvider; _logger = logger; } @@ -25,8 +25,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - await using var scope = _scopeFactory.CreateAsyncScope(); - var indexingService = scope.ServiceProvider.GetRequiredService(); + var indexingService = _serviceProvider.GetRequiredService(); switch (workItem.Type) { diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor index ff02280d..9d4b1297 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor @@ -1026,58 +1026,7 @@

Select the capabilities available to this task.

-
AI Agents
- @if (_model.AvailableAgents.Count == 0) - { -

No agent profiles available.

- } - else - { - @foreach (var agent in _model.AvailableAgents) - { -
- - -
- } - } -
A2A Hosts
- @if (_model.AvailableA2AConnections.Count == 0) - { -

No A2A hosts configured.

- } - else - { - @foreach (var conn in _model.AvailableA2AConnections) - { -
- - -
- } - } -
MCP Hosts
- @if (_model.AvailableMcpConnections.Count == 0) - { -

No MCP hosts configured.

- } - else - { - @foreach (var conn in _model.AvailableMcpConnections) - { -
- - -
- } - } -
AI Tools
+
AI Tools
@if (_model.AvailableTools.Count == 0) {

No AI tools registered.

@@ -1354,30 +1303,6 @@ private string GetTaskActiveTab(int index) => _taskActiveTabs.TryGetValue(index, out var tab) ? tab : "info"; private void SetTaskActiveTab(int index, string tab) => _taskActiveTabs[index] = tab; - private void ToggleTaskAgent(PostSessionTaskItem task, string name, bool selected) - { - var list = task.SelectedAgentNames.ToList(); - if (selected && !list.Contains(name)) list.Add(name); - else if (!selected) list.Remove(name); - task.SelectedAgentNames = list.ToArray(); - } - - private void ToggleTaskA2A(PostSessionTaskItem task, string id, bool selected) - { - var list = task.SelectedA2AConnectionIds.ToList(); - if (selected && !list.Contains(id)) list.Add(id); - else if (!selected) list.Remove(id); - task.SelectedA2AConnectionIds = list.ToArray(); - } - - private void ToggleTaskMcp(PostSessionTaskItem task, string id, bool selected) - { - var list = task.SelectedMcpConnectionIds.ToList(); - if (selected && !list.Contains(id)) list.Add(id); - else if (!selected) list.Remove(id); - task.SelectedMcpConnectionIds = list.ToArray(); - } - private void ToggleTaskTool(PostSessionTaskItem task, string name, bool selected) { var list = task.SelectedToolNames.ToList(); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor index b4d26652..50cbfdb4 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor @@ -952,58 +952,7 @@ else if (_model != null)

Select the capabilities available to this task.

-
AI Agents
- @if (_model.AvailableAgents.Count == 0) - { -

No agent profiles available.

- } - else - { - @foreach (var agent in _model.AvailableAgents) - { -
- - -
- } - } -
A2A Hosts
- @if (_model.AvailableA2AConnections.Count == 0) - { -

No A2A hosts configured.

- } - else - { - @foreach (var conn in _model.AvailableA2AConnections) - { -
- - -
- } - } -
MCP Hosts
- @if (_model.AvailableMcpConnections.Count == 0) - { -

No MCP hosts configured.

- } - else - { - @foreach (var conn in _model.AvailableMcpConnections) - { -
- - -
- } - } -
AI Tools
+
AI Tools
@if (_model.AvailableTools.Count == 0) {

No AI tools registered.

@@ -1214,30 +1163,6 @@ else if (_model != null) private string GetTaskActiveTab(int index) => _taskActiveTabs.TryGetValue(index, out var tab) ? tab : "info"; private void SetTaskActiveTab(int index, string tab) => _taskActiveTabs[index] = tab; - private void ToggleTaskAgent(PostSessionTaskItem task, string name, bool selected) - { - var list = task.SelectedAgentNames.ToList(); - if (selected && !list.Contains(name)) list.Add(name); - else if (!selected) list.Remove(name); - task.SelectedAgentNames = list.ToArray(); - } - - private void ToggleTaskA2A(PostSessionTaskItem task, string id, bool selected) - { - var list = task.SelectedA2AConnectionIds.ToList(); - if (selected && !list.Contains(id)) list.Add(id); - else if (!selected) list.Remove(id); - task.SelectedA2AConnectionIds = list.ToArray(); - } - - private void ToggleTaskMcp(PostSessionTaskItem task, string id, bool selected) - { - var list = task.SelectedMcpConnectionIds.ToList(); - if (selected && !list.Contains(id)) list.Add(id); - else if (!selected) list.Remove(id); - task.SelectedMcpConnectionIds = list.ToArray(); - } - private void ToggleTaskTool(PostSessionTaskItem task, string name, bool selected) { var list = task.SelectedToolNames.ToList(); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs index 77d9404f..e49e4e17 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs @@ -248,9 +248,6 @@ public static AIProfileViewModel FromProfile(AIProfile profile) AllowMultipleValues = t.AllowMultipleValues, Options = string.Join(Environment.NewLine, t.Options.Select(o => o.Value)), SelectedToolNames = t.ToolNames ?? [], - SelectedAgentNames = t.AgentNames ?? [], - SelectedA2AConnectionIds = t.A2AConnectionIds ?? [], - SelectedMcpConnectionIds = t.McpConnectionIds ?? [], }).ToList(), EnableUserMemory = memoryMetadata.EnableUserMemory ?? false, @@ -551,9 +548,6 @@ public void ApplyTo(AIProfile profile) .Select(o => new PostSessionTaskOption { Value = o.Trim() }) .ToList(), ToolNames = t.SelectedToolNames ?? [], - AgentNames = t.SelectedAgentNames ?? [], - A2AConnectionIds = t.SelectedA2AConnectionIds ?? [], - McpConnectionIds = t.SelectedMcpConnectionIds ?? [], }).ToList(); }); @@ -630,12 +624,6 @@ public sealed class PostSessionTaskItem public string Options { get; set; } public string[] SelectedToolNames { get; set; } = []; - - public string[] SelectedAgentNames { get; set; } = []; - - public string[] SelectedA2AConnectionIds { get; set; } = []; - - public string[] SelectedMcpConnectionIds { get; set; } = []; } public sealed class PromptTemplateSelectionItem diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AITemplateViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AITemplateViewModel.cs index 82aa436f..f844cb7c 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AITemplateViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AITemplateViewModel.cs @@ -318,9 +318,6 @@ public static AITemplateViewModel FromTemplate(AIProfileTemplate template) AllowMultipleValues = t.AllowMultipleValues, Options = string.Join(Environment.NewLine, t.Options.Select(o => o.Value)), SelectedToolNames = t.ToolNames ?? [], - SelectedAgentNames = t.AgentNames ?? [], - SelectedA2AConnectionIds = t.A2AConnectionIds ?? [], - SelectedMcpConnectionIds = t.McpConnectionIds ?? [], }) .ToList(); } @@ -524,9 +521,6 @@ public void ApplyTo(AIProfileTemplate template) .Select(o => new PostSessionTaskOption { Value = o.Trim() }) .ToList(), ToolNames = t.SelectedToolNames ?? [], - AgentNames = t.SelectedAgentNames ?? [], - A2AConnectionIds = t.SelectedA2AConnectionIds ?? [], - McpConnectionIds = t.SelectedMcpConnectionIds ?? [], }) .ToList(), }); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs index f5f30caf..59689731 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs @@ -235,9 +235,6 @@ public static AIProfileViewModel FromProfile(AIProfile profile) AllowMultipleValues = t.AllowMultipleValues, Options = string.Join(Environment.NewLine, t.Options.Select(o => o.Value)), SelectedToolNames = t.ToolNames ?? [], - SelectedAgentNames = t.AgentNames ?? [], - SelectedA2AConnectionIds = t.A2AConnectionIds ?? [], - SelectedMcpConnectionIds = t.McpConnectionIds ?? [], }).ToList(), EnableUserMemory = memoryMetadata.EnableUserMemory ?? false, }; @@ -539,9 +536,6 @@ public void ApplyTo(AIProfile profile) .Select(o => new PostSessionTaskOption { Value = o.Trim() }) .ToList(), ToolNames = t.SelectedToolNames ?? [], - AgentNames = t.SelectedAgentNames ?? [], - A2AConnectionIds = t.SelectedA2AConnectionIds ?? [], - McpConnectionIds = t.SelectedMcpConnectionIds ?? [], }).ToList(); }); @@ -619,12 +613,6 @@ public sealed class PostSessionTaskItem public string Options { get; set; } public string[] SelectedToolNames { get; set; } = []; - - public string[] SelectedAgentNames { get; set; } = []; - - public string[] SelectedA2AConnectionIds { get; set; } = []; - - public string[] SelectedMcpConnectionIds { get; set; } = []; } public sealed class PromptTemplateSelectionItem diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs index c4995df8..bd320f18 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs @@ -302,9 +302,6 @@ public static AITemplateViewModel FromTemplate(AIProfileTemplate template) AllowMultipleValues = t.AllowMultipleValues, Options = string.Join(Environment.NewLine, t.Options.Select(o => o.Value)), SelectedToolNames = t.ToolNames ?? [], - SelectedAgentNames = t.AgentNames ?? [], - SelectedA2AConnectionIds = t.A2AConnectionIds ?? [], - SelectedMcpConnectionIds = t.McpConnectionIds ?? [], }) .ToList(); } @@ -508,9 +505,6 @@ public void ApplyTo(AIProfileTemplate template) .Select(o => new PostSessionTaskOption { Value = o.Trim() }) .ToList(), ToolNames = t.SelectedToolNames ?? [], - AgentNames = t.SelectedAgentNames ?? [], - A2AConnectionIds = t.SelectedA2AConnectionIds ?? [], - McpConnectionIds = t.SelectedMcpConnectionIds ?? [], }) .ToList(), }); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml index d267d171..ce9b9c56 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml @@ -1170,24 +1170,12 @@ let taskIndex = 0; const taskCapabilities = { - agents: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableAgents.Select(a => new { a.Name, a.DisplayText }))), - a2a: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableA2AConnections.Select(c => new { c.ItemId, c.DisplayText }))), - mcp: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableMcpConnections.Select(c => new { c.ItemId, c.DisplayText }))), tools: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableTools.GroupBy(t => t.Category).OrderBy(g => g.Key).Select(g => new { Category = g.Key, Items = g.Select(t => new { t.Name, t.Title }) }))) }; function buildTaskCapabilitiesHtml(idx) { let html = '

Select the capabilities available to this task.

'; - html += '
AI Agents
'; - if (taskCapabilities.agents.length === 0) { html += '

No agent profiles available.

'; } - else { taskCapabilities.agents.forEach(a => { html += `
`; }); } - html += '
A2A Hosts
'; - if (taskCapabilities.a2a.length === 0) { html += '

No A2A hosts configured.

'; } - else { taskCapabilities.a2a.forEach(c => { html += `
`; }); } - html += '
MCP Hosts
'; - if (taskCapabilities.mcp.length === 0) { html += '

No MCP hosts configured.

'; } - else { taskCapabilities.mcp.forEach(c => { html += `
`; }); } - html += '
AI Tools
'; + html += '
AI Tools
'; if (taskCapabilities.tools.length === 0) { html += '

No AI tools registered.

'; } else { taskCapabilities.tools.forEach(g => { html += `
${g.Category}
`; g.Items.forEach(t => { html += `
`; }); }); } return html; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml index c4252bc8..2d329bfa 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml @@ -860,55 +860,7 @@

Select the capabilities available to this task.

-
AI Agents
- @if (Model.AvailableAgents.Count == 0) - { -

No agent profiles available.

- } - else - { - @foreach (var agent in Model.AvailableAgents) - { -
- - -
- } - } -
A2A Hosts
- @if (Model.AvailableA2AConnections.Count == 0) - { -

No A2A hosts configured.

- } - else - { - @foreach (var conn in Model.AvailableA2AConnections) - { -
- - -
- } - } -
MCP Hosts
- @if (Model.AvailableMcpConnections.Count == 0) - { -

No MCP hosts configured.

- } - else - { - @foreach (var conn in Model.AvailableMcpConnections) - { -
- - -
- } - } -
AI Tools
+
AI Tools
@if (Model.AvailableTools.Count == 0) {

No AI tools registered.

@@ -1416,24 +1368,12 @@ var taskIndex = @Model.PostSessionTasks.Count; var taskCapabilities = { - agents: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableAgents.Select(a => new { a.Name, a.DisplayText }))), - a2a: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableA2AConnections.Select(c => new { c.ItemId, c.DisplayText }))), - mcp: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableMcpConnections.Select(c => new { c.ItemId, c.DisplayText }))), tools: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableTools.GroupBy(t => t.Category).OrderBy(g => g.Key).Select(g => new { Category = g.Key, Items = g.Select(t => new { t.Name, t.Title }) }))) }; function buildTaskCapabilitiesHtml(idx) { var html = '

Select the capabilities available to this task.

'; - html += '
AI Agents
'; - if (taskCapabilities.agents.length === 0) { html += '

No agent profiles available.

'; } - else { taskCapabilities.agents.forEach(function (a) { html += '
'; }); } - html += '
A2A Hosts
'; - if (taskCapabilities.a2a.length === 0) { html += '

No A2A hosts configured.

'; } - else { taskCapabilities.a2a.forEach(function (c) { html += '
'; }); } - html += '
MCP Hosts
'; - if (taskCapabilities.mcp.length === 0) { html += '

No MCP hosts configured.

'; } - else { taskCapabilities.mcp.forEach(function (c) { html += '
'; }); } - html += '
AI Tools
'; + html += '
AI Tools
'; if (taskCapabilities.tools.length === 0) { html += '

No AI tools registered.

'; } else { taskCapabilities.tools.forEach(function (g) { html += '
' + g.Category + '
'; g.Items.forEach(function (t) { html += '
'; }); }); } return html; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml index dc46e74f..8e91c41b 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml @@ -1064,24 +1064,12 @@ var taskIndex = 0; var taskCapabilities = { - agents: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableAgents.Select(a => new { a.Name, a.DisplayText }))), - a2a: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableA2AConnections.Select(c => new { c.ItemId, c.DisplayText }))), - mcp: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableMcpConnections.Select(c => new { c.ItemId, c.DisplayText }))), tools: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableTools.GroupBy(t => t.Category).OrderBy(g => g.Key).Select(g => new { Category = g.Key, Items = g.Select(t => new { t.Name, t.Title }) }))) }; function buildTaskCapabilitiesHtml(idx) { var html = '

Select the capabilities available to this task.

'; - html += '
AI Agents
'; - if (taskCapabilities.agents.length === 0) { html += '

No agent profiles available.

'; } - else { taskCapabilities.agents.forEach(function (a) { html += '
'; }); } - html += '
A2A Hosts
'; - if (taskCapabilities.a2a.length === 0) { html += '

No A2A hosts configured.

'; } - else { taskCapabilities.a2a.forEach(function (c) { html += '
'; }); } - html += '
MCP Hosts
'; - if (taskCapabilities.mcp.length === 0) { html += '

No MCP hosts configured.

'; } - else { taskCapabilities.mcp.forEach(function (c) { html += '
'; }); } - html += '
AI Tools
'; + html += '
AI Tools
'; if (taskCapabilities.tools.length === 0) { html += '

No AI tools registered.

'; } else { taskCapabilities.tools.forEach(function (g) { html += '
' + g.Category + '
'; g.Items.forEach(function (t) { html += '
'; }); }); } return html; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml index 919d7d9f..f4ce4e8b 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml @@ -749,55 +749,7 @@

Select the capabilities available to this task.

-
AI Agents
- @if (Model.AvailableAgents.Count == 0) - { -

No agent profiles available.

- } - else - { - @foreach (var agent in Model.AvailableAgents) - { -
- - -
- } - } -
A2A Hosts
- @if (Model.AvailableA2AConnections.Count == 0) - { -

No A2A hosts configured.

- } - else - { - @foreach (var conn in Model.AvailableA2AConnections) - { -
- - -
- } - } -
MCP Hosts
- @if (Model.AvailableMcpConnections.Count == 0) - { -

No MCP hosts configured.

- } - else - { - @foreach (var conn in Model.AvailableMcpConnections) - { -
- - -
- } - } -
AI Tools
+
AI Tools
@if (Model.AvailableTools.Count == 0) {

No AI tools registered.

@@ -1340,24 +1292,12 @@ var taskIndex = @Model.PostSessionTasks.Count; var taskCapabilities = { - agents: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableAgents.Select(a => new { a.Name, a.DisplayText }))), - a2a: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableA2AConnections.Select(c => new { c.ItemId, c.DisplayText }))), - mcp: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableMcpConnections.Select(c => new { c.ItemId, c.DisplayText }))), tools: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableTools.GroupBy(t => t.Category).OrderBy(g => g.Key).Select(g => new { Category = g.Key, Items = g.Select(t => new { t.Name, t.Title }) }))) }; function buildTaskCapabilitiesHtml(idx) { var html = '

Select the capabilities available to this task.

'; - html += '
AI Agents
'; - if (taskCapabilities.agents.length === 0) { html += '

No agent profiles available.

'; } - else { taskCapabilities.agents.forEach(function (a) { html += '
'; }); } - html += '
A2A Hosts
'; - if (taskCapabilities.a2a.length === 0) { html += '

No A2A hosts configured.

'; } - else { taskCapabilities.a2a.forEach(function (c) { html += '
'; }); } - html += '
MCP Hosts
'; - if (taskCapabilities.mcp.length === 0) { html += '

No MCP hosts configured.

'; } - else { taskCapabilities.mcp.forEach(function (c) { html += '
'; }); } - html += '
AI Tools
'; + html += '
AI Tools
'; if (taskCapabilities.tools.length === 0) { html += '

No AI tools registered.

'; } else { taskCapabilities.tools.forEach(function (g) { html += '
' + g.Category + '
'; g.Items.forEach(function (t) { html += '
'; }); }); } return html; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs index e837d778..3ab821bb 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/BackgroundServices/AIChatDocumentIndexingBackgroundService.cs @@ -6,16 +6,16 @@ namespace CrestApps.Core.Mvc.Web.Areas.AIChat.BackgroundServices; public sealed class AIChatDocumentIndexingBackgroundService : BackgroundService { private readonly SampleAIChatDocumentIndexingQueue _queue; - private readonly IServiceScopeFactory _scopeFactory; + private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; public AIChatDocumentIndexingBackgroundService( SampleAIChatDocumentIndexingQueue queue, - IServiceScopeFactory scopeFactory, + IServiceProvider serviceProvider, ILogger logger) { _queue = queue; - _scopeFactory = scopeFactory; + _serviceProvider = serviceProvider; _logger = logger; } @@ -25,8 +25,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - await using var scope = _scopeFactory.CreateAsyncScope(); - var indexingService = scope.ServiceProvider.GetRequiredService(); + var indexingService = _serviceProvider.GetRequiredService(); switch (workItem.Type) { diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index f5a6e7b4..60442233 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -16,6 +16,7 @@ using CrestApps.Core.AI.Mcp.Ftp; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Mcp.Sftp; +using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Ollama; using CrestApps.Core.AI.OpenAI; using CrestApps.Core.AI.OpenAI.Azure; @@ -145,6 +146,12 @@ ) ); +builder.Services.Configure(o => +{ + // This code will be removed in the v3. We'll keep it now for backward compatibility. + o.ProviderSections.Add("CrestApps:CrestApps_AI:Providers"); +}); + // ============================================================================= // 4. MCP AND CUSTOM TOOLS // ============================================================================= diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index b8a50ef6..ea6d000f 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -197,6 +197,7 @@ public static IServiceCollection AddCoreAIChatSessionStoresEntityCore(this IServ ArgumentNullException.ThrowIfNull(services); services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); services.Replace(ServiceDescriptor.Scoped()); services.Replace(ServiceDescriptor.Scoped()); services.Replace(ServiceDescriptor.Scoped()); diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionStore.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionStore.cs new file mode 100644 index 00000000..c69d7271 --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionStore.cs @@ -0,0 +1,152 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.EntityCore.Models; +using Microsoft.EntityFrameworkCore; + +namespace CrestApps.Core.Data.EntityCore.Services; + +/// +/// EntityCore implementation of providing unscoped +/// access to AI chat sessions for background processing and administrative operations. +/// +public sealed class EntityCoreAIChatSessionStore : IAIChatSessionStore +{ + private readonly CrestAppsEntityDbContext _dbContext; + + /// + /// Initializes a new instance of the class. + /// + /// The database context. + public EntityCoreAIChatSessionStore(CrestAppsEntityDbContext dbContext) + { + _dbContext = dbContext; + } + + /// + /// Finds a chat session by its session identifier without user-scoping. + /// + /// The unique session identifier. + /// The cancellation token. + /// The matching session, or if not found. + public async Task FindByIdAsync(string sessionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(sessionId); + + var record = await _dbContext.AIChatSessionRecords + .AsNoTracking() + .Include(x => x.Document) + .FirstOrDefaultAsync(x => x.SessionId == sessionId, cancellationToken); + + return record is null ? null : Materialize(record); + } + + /// + /// Retrieves all active sessions for the specified profile that have been inactive + /// since before the given cutoff time. + /// + /// The profile identifier. + /// The UTC cutoff time. + /// The cancellation token. + /// A read-only list of inactive active sessions. + public async Task> GetInactiveActiveSessionsAsync( + string profileId, + DateTime cutoffUtc, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(profileId); + + var records = await _dbContext.AIChatSessionRecords + .AsNoTracking() + .Include(x => x.Document) + .Where(x => x.ProfileId == profileId + && x.Status == ChatSessionStatus.Active + && x.LastActivityUtc < cutoffUtc) + .ToListAsync(cancellationToken); + + return records.Select(Materialize).ToList(); + } + + /// + /// Retrieves all closed or abandoned sessions for the specified profile. + /// + /// The profile identifier. + /// The cancellation token. + /// A read-only list of closed or abandoned sessions. + public async Task> GetClosedSessionsAsync( + string profileId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(profileId); + + var records = await _dbContext.AIChatSessionRecords + .AsNoTracking() + .Include(x => x.Document) + .Where(x => x.ProfileId == profileId + && (x.Status == ChatSessionStatus.Closed || x.Status == ChatSessionStatus.Abandoned)) + .ToListAsync(cancellationToken); + + return records.Select(Materialize).ToList(); + } + + /// + /// Persists the specified chat session. + /// + /// The chat session to save. + /// The cancellation token. + public async Task SaveAsync(AIChatSession chatSession, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(chatSession); + + var record = await _dbContext.AIChatSessionRecords + .Include(x => x.Document) + .FirstOrDefaultAsync(x => x.SessionId == chatSession.SessionId, cancellationToken); + + if (record is null) + { + _dbContext.AIChatSessionRecords.Add(CreateRecord(chatSession)); + } + else + { + UpdateRecord(record, chatSession); + } + + await _dbContext.SaveChangesAsync(cancellationToken); + } + + private static AIChatSession Materialize(AIChatSessionRecord record) + { + return EntityCoreStoreSerializer.Deserialize(record.Document.Content); + } + + private static AIChatSessionRecord CreateRecord(AIChatSession session) + { + return new() + { + Document = new DocumentRecord + { + Type = typeof(AIChatSession).FullName!, + Content = EntityCoreStoreSerializer.Serialize(session), + }, + SessionId = session.SessionId, + ProfileId = session.ProfileId, + Title = session.Title, + UserId = session.UserId, + ClientId = session.ClientId, + Status = session.Status, + CreatedUtc = session.CreatedUtc, + LastActivityUtc = session.LastActivityUtc, + }; + } + + private static void UpdateRecord(AIChatSessionRecord record, AIChatSession session) + { + record.ProfileId = session.ProfileId; + record.Title = session.Title; + record.UserId = session.UserId; + record.ClientId = session.ClientId; + record.Status = session.Status; + record.CreatedUtc = session.CreatedUtc; + record.LastActivityUtc = session.LastActivityUtc; + record.Document.Content = EntityCoreStoreSerializer.Serialize(session); + } +} diff --git a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs index a75b92b6..9597affc 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs @@ -380,6 +380,7 @@ public static IServiceCollection AddCoreAIChatSessionBaseStoresYesSql(this IServ ArgumentNullException.ThrowIfNull(services); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.TryAddEnumerable(ServiceDescriptor.Singleton()); diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionStore.cs b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionStore.cs new file mode 100644 index 00000000..27491449 --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionStore.cs @@ -0,0 +1,141 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.YesSql.Indexes.AIChat; +using Microsoft.Extensions.Options; +using YesSql; +using ISession = YesSql.ISession; + +namespace CrestApps.Core.Data.YesSql.Services; + +/// +/// YesSql implementation of providing unscoped +/// access to AI chat sessions for background processing and administrative operations. +/// +public sealed class YesSqlAIChatSessionStore : IAIChatSessionStore +{ + private readonly ISession _session; + private readonly string _collection; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + /// The YesSql store options. + public YesSqlAIChatSessionStore( + ISession session, + IOptions options) + { + _session = session; + _collection = options.Value.AICollectionName; + } + + /// + /// Finds a chat session by its unique session identifier. + /// + /// The unique identifier of the chat session. + /// The cancellation token. + /// The matching session, or if not found. + public async Task FindByIdAsync(string sessionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(sessionId); + + return await _session.Query( + x => x.SessionId == sessionId, + collection: _collection) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + /// Retrieves all active sessions for the specified profile that have been inactive + /// since before the given cutoff time. + /// + /// The profile identifier. + /// The UTC cutoff time. + /// The cancellation token. + /// A read-only list of inactive active sessions. + public async Task> GetInactiveActiveSessionsAsync( + string profileId, + DateTime cutoffUtc, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(profileId); + + var sessions = await _session.Query( + i => i.ProfileId == profileId && i.Status == ChatSessionStatus.Active && i.LastActivityUtc < cutoffUtc, + collection: _collection) + .ListAsync(cancellationToken); + + return sessions.ToList(); + } + + /// + /// Retrieves all closed or abandoned sessions for the specified profile. + /// + /// The profile identifier. + /// The cancellation token. + /// A read-only list of closed or abandoned sessions. + public async Task> GetClosedSessionsAsync( + string profileId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(profileId); + + var sessions = await _session.Query( + i => i.ProfileId == profileId + && (i.Status == ChatSessionStatus.Closed || i.Status == ChatSessionStatus.Abandoned), + collection: _collection) + .ListAsync(cancellationToken); + + return sessions.ToList(); + } + + /// + /// Persists the specified chat session. + /// + /// The chat session to save. + /// The cancellation token. + public async Task SaveAsync(AIChatSession chatSession, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(chatSession); + + var storedSession = await FindByIdAsync(chatSession.SessionId, cancellationToken); + + if (storedSession == null) + { + await _session.SaveAsync(chatSession, _collection); + + return; + } + + if (!ReferenceEquals(storedSession, chatSession)) + { + CopySession(chatSession, storedSession); + } + + await _session.SaveAsync(storedSession, _collection); + } + + private static void CopySession(AIChatSession source, AIChatSession destination) + { + destination.SessionId = source.SessionId; + destination.ProfileId = source.ProfileId; + destination.Title = source.Title; + destination.UserId = source.UserId; + destination.ClientId = source.ClientId; + destination.Documents = source.Documents == null ? [] : [.. source.Documents]; + destination.CreatedUtc = source.CreatedUtc; + destination.LastActivityUtc = source.LastActivityUtc; + destination.ClosedAtUtc = source.ClosedAtUtc; + destination.Status = source.Status; + destination.ResponseHandlerName = source.ResponseHandlerName; + destination.ExtractedData = source.ExtractedData == null ? [] : new Dictionary(source.ExtractedData); + destination.PostSessionResults = source.PostSessionResults == null ? [] : new Dictionary(source.PostSessionResults); + destination.PostSessionProcessingStatus = source.PostSessionProcessingStatus; + destination.PostSessionProcessingAttempts = source.PostSessionProcessingAttempts; + destination.PostSessionProcessingLastAttemptUtc = source.PostSessionProcessingLastAttemptUtc; + destination.IsPostSessionTasksProcessed = source.IsPostSessionTasksProcessed; + destination.IsAnalyticsRecorded = source.IsAnalyticsRecorded; + destination.IsConversionGoalsEvaluated = source.IsConversionGoalsEvaluated; + destination.Properties = source.Properties == null ? [] : new Dictionary(source.Properties); + } +} diff --git a/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs b/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs index 60f02130..18ceb262 100644 --- a/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs @@ -1,10 +1,10 @@ using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Chat.Services; -using CrestApps.Core.AI; using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Profiles; +using CrestApps.Core.AI.Tooling; using CrestApps.Core.Templates.Parsing; using CrestApps.Core.Templates.Services; using Microsoft.Extensions.DependencyInjection; @@ -240,7 +240,7 @@ private static AIChatSessionPostCloseProcessor CreatePostCloseProcessor(TimeProv var postSessionProcessingService = new PostSessionProcessingService( Mock.Of(), - Mock.Of(), + Mock.Of(), templateService.Object, [markdownParser.Object], new DefaultAIOptions(), diff --git a/tests/CrestApps.Core.Tests/Core/Services/PostSession/AIChatSessionPostCloseProcessorTests.cs b/tests/CrestApps.Core.Tests/Core/Services/PostSession/AIChatSessionPostCloseProcessorTests.cs index a54e914b..e09fe2d0 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/PostSession/AIChatSessionPostCloseProcessorTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/PostSession/AIChatSessionPostCloseProcessorTests.cs @@ -1,8 +1,8 @@ -using CrestApps.Core.AI; using CrestApps.Core.AI.Chat.Services; using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Tooling; using CrestApps.Core.Templates.Parsing; using CrestApps.Core.Templates.Services; using Microsoft.Extensions.AI; @@ -319,7 +319,7 @@ private static PostSessionProcessingService CreatePostSessionService( return new PostSessionProcessingService( mockClientFactory.Object, - Mock.Of(), + Mock.Of(), mockTemplateService.Object, [new DefaultMarkdownTemplateParser()], new DefaultAIOptions diff --git a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs index df02aa98..2620c80c 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs @@ -3,6 +3,7 @@ using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Tooling; using CrestApps.Core.Templates.Parsing; using CrestApps.Core.Templates.Services; using Microsoft.Extensions.AI; @@ -154,8 +155,8 @@ public async Task ProcessAsync_WithTaskScopedToolNames_ShouldResolveToolsAndUseT var session = CreateSession(); var prompts = CreatePrompts(); var mockTool = new TestAIFunction("sendEmail"); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { CreateToolEntry("sendEmail", mockTool) }); var mockChatClient = new Mock(); // The tools path calls non-generic GetResponseAsync. // Simulate response with a JSON result. @@ -168,13 +169,13 @@ public async Task ProcessAsync_WithTaskScopedToolNames_ShouldResolveToolsAndUseT mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); // Act var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); // Assert: tools service was asked to resolve the tool. - mockToolsService.Verify(t => t.GetByNameAsync("sendEmail"), Times.Once); + mockToolRegistry.Verify(t => t.GetAllAsync(It.IsAny(), It.IsAny()), Times.Once); // Assert: the chat client was invoked with tools in the options. mockChatClient.Verify(c => c.GetResponseAsync(It.IsAny>(), It.Is(opts => opts.Tools != null && opts.Tools.Count > 0), It.IsAny()), Times.Once); @@ -198,8 +199,8 @@ public async Task ProcessAsync_WhenToolResponseContainsOnlyInvalidTaskEntriesWit var session = CreateSession(); var prompts = CreatePrompts(); var mockTool = new TestAIFunction("sendEmail"); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { CreateToolEntry("sendEmail", mockTool) }); var mockChatClient = new Mock(); mockChatClient.SetupSequence(c => c .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) @@ -210,7 +211,7 @@ public async Task ProcessAsync_WhenToolResponseContainsOnlyInvalidTaskEntriesWit mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); @@ -239,8 +240,8 @@ public async Task ProcessAsync_WhenToolResponseContainsEmptyTasksArray_ShouldRet var session = CreateSession(); var prompts = CreatePrompts(); var mockTool = new TestAIFunction("sendEmail"); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { CreateToolEntry("sendEmail", mockTool) }); var mockChatClient = new Mock(); mockChatClient.Setup(c => c .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) @@ -249,7 +250,7 @@ public async Task ProcessAsync_WhenToolResponseContainsEmptyTasksArray_ShouldRet mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); @@ -277,8 +278,8 @@ public async Task ProcessAsync_WhenToolNotFound_ShouldLogWarningAndFallToStructu }); var session = CreateSession(); var prompts = CreatePrompts(); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("nonExistentTool")).ReturnsAsync((AITool)null); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); var mockChatClient = new Mock(); mockChatClient.Setup(c => c .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) @@ -287,13 +288,13 @@ public async Task ProcessAsync_WhenToolNotFound_ShouldLogWarningAndFallToStructu mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); // Act var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); // Assert: tool resolution was attempted. - mockToolsService.Verify(t => t.GetByNameAsync("nonExistentTool"), Times.Once); + mockToolRegistry.Verify(t => t.GetAllAsync(It.IsAny(), It.IsAny()), Times.Once); // Assert: when no tools resolve, the structured output path is used (no tools in options). mockChatClient.Verify(c => c.GetResponseAsync(It.IsAny>(), It.Is(opts => opts.Tools == null || opts.Tools.Count == 0), It.IsAny()), Times.Once); @@ -557,7 +558,7 @@ private static List CreateAssistantOnlyPrompts() ]; } - private static PostSessionProcessingService CreateService(IChatClient chatClient = null, IAIToolsService toolsService = null, ITemplateService templateService = null) + private static PostSessionProcessingService CreateService(IChatClient chatClient = null, IToolRegistry toolRegistry = null, ITemplateService templateService = null) { var mockClientFactory = new Mock(); if (chatClient is not null) @@ -583,7 +584,7 @@ private static PostSessionProcessingService CreateService(IChatClient chatClient .ReturnsAsync(deployment); } - var mockToolsService = toolsService is not null ? null : new Mock(); + var mockToolRegistry = toolRegistry is not null ? null : new Mock(); var mockTemplateService = templateService is not null ? null : new Mock(); // Set up default template renders. if (mockTemplateService is not null) @@ -600,7 +601,7 @@ private static PostSessionProcessingService CreateService(IChatClient chatClient return new PostSessionProcessingService( mockClientFactory.Object, - toolsService ?? mockToolsService.Object, + toolRegistry ?? mockToolRegistry.Object, templateService ?? mockTemplateService.Object, [new DefaultMarkdownTemplateParser()], defaultOptions, @@ -637,6 +638,17 @@ protected override ValueTask InvokeCoreAsync(AIFunctionArguments argumen } } + private static ToolRegistryEntry CreateToolEntry(string name, AITool tool) + { + return new ToolRegistryEntry + { + Id = name, + Name = name, + Source = ToolRegistryEntrySource.Local, + CreateAsync = _ => new ValueTask(tool), + }; + } + [Fact] public async Task ProcessAsync_WithTools_WhenResponseIsJsonInCodeFence_ShouldParseSuccessfully() { @@ -656,8 +668,8 @@ public async Task ProcessAsync_WithTools_WhenResponseIsJsonInCodeFence_ShouldPar var session = CreateSession(); var prompts = CreatePrompts(); var mockTool = new TestAIFunction("sendEmail"); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { CreateToolEntry("sendEmail", mockTool) }); var responseText = "```json\n{\"tasks\":[{\"name\":\"summary\",\"value\":\"Customer asked about pricing.\"}]}\n```"; var mockChatClient = new Mock(); mockChatClient.Setup(c => c @@ -667,7 +679,7 @@ public async Task ProcessAsync_WithTools_WhenResponseIsJsonInCodeFence_ShouldPar mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); // Act var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); @@ -698,8 +710,8 @@ public async Task ProcessAsync_WithTools_WhenResponseIsJsonWithSurroundingText_S var session = CreateSession(); var prompts = CreatePrompts(); var mockTool = new TestAIFunction("sendEmail"); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { CreateToolEntry("sendEmail", mockTool) }); var responseText = "Here are the results:\n{\"tasks\":[{\"name\":\"summary\",\"value\":\"Customer asked about pricing.\"}]}\nDone."; var mockChatClient = new Mock(); mockChatClient.Setup(c => c @@ -709,7 +721,7 @@ public async Task ProcessAsync_WithTools_WhenResponseIsJsonWithSurroundingText_S mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); // Act var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); @@ -740,8 +752,8 @@ public async Task ProcessAsync_WithTools_WhenAssistantResponseUsesContentsText_S var session = CreateSession(); var prompts = CreatePrompts(); var mockTool = new TestAIFunction("sendEmail"); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { CreateToolEntry("sendEmail", mockTool) }); var responseMessage = new ChatMessage { Role = ChatRole.Assistant, @@ -755,7 +767,7 @@ public async Task ProcessAsync_WithTools_WhenAssistantResponseUsesContentsText_S mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); // Act var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); @@ -786,8 +798,8 @@ public async Task ProcessAsync_WithTools_WhenResponseIsTruncatedJson_ShouldRecov var session = CreateSession(); var prompts = CreatePrompts(); var mockTool = new TestAIFunction("sendEmail"); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { CreateToolEntry("sendEmail", mockTool) }); var mockChatClient = new Mock(); mockChatClient.SetupSequence(c => c .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) @@ -797,7 +809,7 @@ public async Task ProcessAsync_WithTools_WhenResponseIsTruncatedJson_ShouldRecov mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); // Act var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); @@ -829,8 +841,8 @@ public async Task ProcessAsync_WithTools_WhenStructuredRetryReturnsMarkdownWrapp var session = CreateSession(); var prompts = CreatePrompts(); var mockTool = new TestAIFunction("sendEmail"); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { CreateToolEntry("sendEmail", mockTool) }); var mockChatClient = new Mock(); mockChatClient.SetupSequence(c => c .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) @@ -854,7 +866,7 @@ public async Task ProcessAsync_WithTools_WhenStructuredRetryReturnsMarkdownWrapp mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); // Act var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); @@ -886,8 +898,8 @@ public async Task ProcessAsync_WithTools_WhenSingleSemanticTaskAndNonJsonRespons var session = CreateSession(); var prompts = CreatePrompts(); var mockTool = new TestAIFunction("sendEmail"); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { CreateToolEntry("sendEmail", mockTool) }); var responseText = "The customer asked about pricing options."; var mockChatClient = new Mock(); mockChatClient.Setup(c => c @@ -897,7 +909,7 @@ public async Task ProcessAsync_WithTools_WhenSingleSemanticTaskAndNonJsonRespons mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); // Act var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); @@ -939,8 +951,8 @@ public async Task ProcessAsync_WithTools_WhenMultipleTasksAndNonJsonResponse_Sho var session = CreateSession(); var prompts = CreatePrompts(); var mockTool = new TestAIFunction("sendEmail"); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { CreateToolEntry("sendEmail", mockTool) }); var mockChatClient = new Mock(); mockChatClient.Setup(c => c .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) @@ -949,7 +961,7 @@ public async Task ProcessAsync_WithTools_WhenMultipleTasksAndNonJsonResponse_Sho mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); // Act var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); @@ -982,8 +994,8 @@ public async Task ProcessAsync_WithTools_WhenResponseIsEmpty_ShouldReturnFailedR var session = CreateSession(); var prompts = CreatePrompts(); var mockTool = new TestAIFunction("sendEmail"); - var mockToolsService = new Mock(); - mockToolsService.Setup(t => t.GetByNameAsync("sendEmail")).ReturnsAsync(mockTool); + var mockToolRegistry = new Mock(); + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List { CreateToolEntry("sendEmail", mockTool) }); var mockChatClient = new Mock(); mockChatClient.Setup(c => c .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) @@ -992,7 +1004,7 @@ public async Task ProcessAsync_WithTools_WhenResponseIsEmpty_ShouldReturnFailedR mockTemplateService.Setup(t => t .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync("Rendered prompt"); - var service = CreateService(chatClient: mockChatClient.Object, toolsService: mockToolsService.Object, templateService: mockTemplateService.Object); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); // Act var result = await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/AIChatHubCoreTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/AIChatHubCoreTests.cs index 6520112f..a521dcbf 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/AIChatHubCoreTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/AIChatHubCoreTests.cs @@ -90,7 +90,7 @@ public static bool IsEndedStatusForTest(ChatSessionStatus status) Assert.NotNull(method); - return (bool)method.Invoke(null, new object[] { status }); + return (bool)method.Invoke(null, [status]); } } From 815708657677c1e382f6c2e493cf0fe028dc3674 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 4 May 2026 09:13:14 -0700 Subject: [PATCH 2/2] Update Program.cs --- src/Startup/CrestApps.Core.Mvc.Web/Program.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index 60442233..c6c2ef87 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -146,12 +146,6 @@ ) ); -builder.Services.Configure(o => -{ - // This code will be removed in the v3. We'll keep it now for backward compatibility. - o.ProviderSections.Add("CrestApps:CrestApps_AI:Providers"); -}); - // ============================================================================= // 4. MCP AND CUSTOM TOOLS // =============================================================================