diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfilePostSessionSettings.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfilePostSessionSettings.cs index 31054dbf..35456523 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfilePostSessionSettings.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfilePostSessionSettings.cs @@ -22,4 +22,10 @@ public sealed class AIProfilePostSessionSettings /// When tools are configured, the AI model can invoke them during post-session analysis. /// public string[] ToolNames { get; set; } = []; + + /// + /// Gets or sets the AI tool instance names to make available during post-session processing. + /// When instances are configured, the AI model can invoke them during post-session analysis. + /// + public string[] ToolInstanceNames { get; set; } = []; } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs index 332fcc63..4eb9842e 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PostSessionTask.cs @@ -40,4 +40,9 @@ 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 tool instance names available to this task during post-session processing. + /// + public string[] ToolInstanceNames { get; set; } = []; } diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md index 08011b47..96c4ec14 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -118,3 +118,4 @@ description: Initial standalone release notes for the CrestApps.Core repository. - adds a source dropdown to the AI tool instance create form in the MVC and Blazor sample hosts that reveals only the selected source's fields (the source is fixed and shown read-only on edit), and relocates the "AI Tool Instances" admin menu item next to "AI Profiles" in both samples - requires the key, title, and content field mappings when creating or editing an AI data source in the MVC and Blazor sample hosts, and makes every built-in source reader fall back to the document key instead of the serialized source document when no title is mapped, so chat citations never render a full JSON document as a reference title - makes the Copilot CLI acquisition work behind corporate proxies and artifact mirrors, and downloads it only once per machine: `CrestApps.Core.AI.Copilot` now resolves the effective npm registry from `NPM_CONFIG_REGISTRY` or `npm config get registry` before the `GitHub.Copilot.SDK` targets download the CLI tarball (the SDK hardcodes `https://registry.npmjs.org`, and MSBuild's `DownloadFile` task cannot read npm configuration), and redirects the SDK's per-project, per-configuration cache to a shared cache under the NuGet global packages folder so a multi-project solution, a fresh worktree, or a CI agent no longer re-downloads the same large tarball for every project; both behaviors are opt-out through `CopilotResolveNpmRegistry` and `CopilotUseSharedCliCache`, the cache location is configurable through `CopilotCliCacheDir` (point it at a pre-seeded directory to build offline), and an explicitly set `CopilotNpmRegistryUrl`, `CopilotCliBinaryPath`, or `CopilotSkipCliDownload` always takes precedence +- lets post-session processing invoke parameterized AI tool instances through the new `AIProfilePostSessionSettings.ToolInstanceNames` and `PostSessionTask.ToolInstanceNames`, merged and forwarded to the tool registry alongside the equivalent `ToolNames` so configuring only tool instances is enough to enable the tool-driven post-session path, and surfaces the per-task selection on the **Capabilities** tab of each post-session task in the AI profile create and edit screens of both the MVC and Blazor sample hosts diff --git a/src/CrestApps.Core.Docs/docs/core/chat.md b/src/CrestApps.Core.Docs/docs/core/chat.md index 156738b5..11601562 100644 --- a/src/CrestApps.Core.Docs/docs/core/chat.md +++ b/src/CrestApps.Core.Docs/docs/core/chat.md @@ -244,7 +244,9 @@ NewAsync() SaveAsync() (inactivity / explicit close) | `ExtractedData` | `Dictionary` | Extracted conversation fields | | `PostSessionProcessingStatus` | `PostSessionProcessingStatus` | Status of post-session tasks | -Each `PostSessionResults` entry now keeps `AttemptHistory` for failed or incomplete retries, and `ProcessedAtUtc` is only populated once the task reaches a terminal success or final failure state. Pending retries keep their last attempt details in history instead of surfacing a default timestamp. Post-session tool resolution also honors task-scoped `PostSessionTask.ToolNames` in addition to profile-level post-session tool configuration, and post-close retries default to 5 attempts before the task is treated as terminally failed. +Each `PostSessionResults` entry now keeps `AttemptHistory` for failed or incomplete retries, and `ProcessedAtUtc` is only populated once the task reaches a terminal success or final failure state. Pending retries keep their last attempt details in history instead of surfacing a default timestamp. Post-session tool resolution also honors task-scoped `PostSessionTask.ToolNames` and `PostSessionTask.ToolInstanceNames` in addition to profile-level post-session tool configuration, and post-close retries default to 5 attempts before the task is treated as terminally failed. + +Parameterized AI tool instances can also be invoked during post-session analysis alongside regular tools. Configure them at the profile level through `AIProfilePostSessionSettings.ToolInstanceNames`, per task through `PostSessionTask.ToolInstanceNames`, or both; the two sets are merged. Configuring only tool instances is enough to enable the tool-driven post-session path, so no regular tool names are required. In the MVC and Blazor sample hosts, the per-task selection is exposed on the **Capabilities** tab of each post-session task, next to the AI tools picker. Hosts can override that retry cap through the shared `AIChatSessionProcessingOptions.MaxPostCloseAttempts` site setting. The MVC admin settings page surfaces the same value as **Max post-close attempts**, and the shared processor reads it through `IOptionsMonitor<>` so both the default hosted runner and custom schedulers honor the same limit. diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs index e027107d..c8d3b4f0 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs @@ -382,7 +382,7 @@ public async Task> ProcessAsync( new(ChatRole.User, prompt), }; - var tools = await ResolveToolsAsync(session.SessionId, settings.ToolNames, tasksToProcess); + var tools = await ResolveToolsAsync(session.SessionId, settings.ToolNames, settings.ToolInstanceNames, tasksToProcess); // When tools are configured (e.g., sendEmail), use non-generic GetResponseAsync // to allow tool execution. The generic version uses structured output which @@ -1394,11 +1394,13 @@ private async Task GetChatClientAsync(AIProfile profile) private async Task> ResolveToolsAsync( string sessionId, string[] profileToolNames, + string[] profileToolInstanceNames, List tasks) { - var toolNames = CollectToolNames(profileToolNames, tasks); + var toolNames = CollectNames(profileToolNames, tasks, static task => task.ToolNames); + var toolInstanceNames = CollectNames(profileToolInstanceNames, tasks, static task => task.ToolInstanceNames); - if (toolNames is null || toolNames.Length == 0) + if (toolNames.Length == 0 && toolInstanceNames.Length == 0) { if (_logger.IsEnabled(LogLevel.Information)) { @@ -1413,15 +1415,17 @@ private async Task> ResolveToolsAsync( if (_logger.IsEnabled(LogLevel.Information)) { _logger.LogInformation( - "Resolving {ToolCount} tool(s) for post-session processing of session '{SessionId}': [{ToolNames}].", + "Resolving {ToolCount} tool(s) and {ToolInstanceCount} tool instance(s) for post-session processing of session '{SessionId}': [{ToolNames}].", toolNames.Length, + toolInstanceNames.Length, sessionId, - string.Join(", ", toolNames)); + string.Join(", ", toolNames.Concat(toolInstanceNames))); } var completionContext = new AICompletionContext { ToolNames = toolNames, + ToolInstanceNames = toolInstanceNames, }; var entries = await _toolRegistry.GetAllAsync(completionContext); @@ -1433,7 +1437,7 @@ private async Task> ResolveToolsAsync( _logger.LogWarning( "Tool registry returned no entries for post-session processing of session '{SessionId}'. Requested tool names: [{ToolNames}].", sessionId, - string.Join(", ", toolNames)); + string.Join(", ", toolNames.Concat(toolInstanceNames))); } return null; @@ -1481,41 +1485,40 @@ private async Task> ResolveToolsAsync( return tools.Count > 0 ? tools : null; } - private static string[] CollectToolNames( - string[] profileToolNames, - List tasks) + private static string[] CollectNames( + string[] profileNames, + List tasks, + Func taskNamesSelector) { - var toolNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); - if (profileToolNames is not null) + AddNames(names, profileNames); + + if (tasks is not null) { - foreach (var name in profileToolNames) + foreach (var task in tasks) { - if (!string.IsNullOrWhiteSpace(name)) - { - toolNames.Add(name); - } + AddNames(names, taskNamesSelector(task)); } } - if (tasks is not null) + return names.Count > 0 ? [.. names] : []; + } + + private static void AddNames(HashSet target, string[] names) + { + if (names is null) { - foreach (var task in tasks) + return; + } + + foreach (var name in names) + { + if (!string.IsNullOrWhiteSpace(name)) { - if (task.ToolNames is not null) - { - foreach (var name in task.ToolNames) - { - if (!string.IsNullOrWhiteSpace(name)) - { - toolNames.Add(name); - } - } - } + target.Add(name); } } - - return toolNames.Count > 0 ? [.. toolNames] : []; } private async Task RenderTranscriptAsync( 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 1c9a37b3..e44645c1 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 @@ -1077,6 +1077,27 @@ } } } + +
AI Tool Instances
+ @if (_model.AvailableToolInstances.Count == 0) + { +

No tool instances are configured. Add them under AI Tool Instances first.

+ } + else + { + @foreach (var instance in _model.AvailableToolInstances) + { +
+ + +
+ } + } @@ -1287,6 +1308,12 @@ _model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds); _model.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.SelectedToolInstanceNames); + foreach (var task in _model.PostSessionTasks) + { + task.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(task.SelectedToolInstanceNames); + } + + var profile = new AIProfile { Type = AIProfileType.Chat }; _model.ApplyTo(profile); profile.ItemId = Guid.NewGuid().ToString("N"); @@ -1382,6 +1409,14 @@ task.SelectedToolNames = list.ToArray(); } + private void ToggleTaskToolInstance(PostSessionTaskItem task, string name, bool selected) + { + var list = (task.SelectedToolInstanceNames ?? []).ToList(); + if (selected && !list.Contains(name, StringComparer.OrdinalIgnoreCase)) list.Add(name); + else if (!selected) list.RemoveAll(existing => string.Equals(existing, name, StringComparison.OrdinalIgnoreCase)); + task.SelectedToolInstanceNames = list.ToArray(); + } + private IEnumerable> FilteredPromptTemplateGroups => _model.AvailablePromptTemplates .Where(template => string.IsNullOrWhiteSpace(_promptTemplateSearchTerm) || 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 02111225..be183d56 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 @@ -1002,6 +1002,27 @@ else if (_model != null) } } } + +
AI Tool Instances
+ @if (_model.AvailableToolInstances.Count == 0) + { +

No tool instances are configured. Add them under AI Tool Instances first.

+ } + else + { + @foreach (var instance in _model.AvailableToolInstances) + { +
+ + +
+ } + } @@ -1178,6 +1199,12 @@ else if (_model != null) _model.SelectedA2AConnectionIds = await GetValidA2AConnectionIdsAsync(_model.SelectedA2AConnectionIds); _model.SelectedMcpConnectionIds = await GetValidMcpConnectionIdsAsync(_model.SelectedMcpConnectionIds); _model.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(_model.SelectedToolInstanceNames); + + foreach (var task in _model.PostSessionTasks) + { + task.SelectedToolInstanceNames = await GetValidToolInstanceNamesAsync(task.SelectedToolInstanceNames); + } + _model.ApplyTo(existing); if (_removedDocumentIds.Count > 0) @@ -1233,6 +1260,14 @@ else if (_model != null) task.SelectedToolNames = list.ToArray(); } + private void ToggleTaskToolInstance(PostSessionTaskItem task, string name, bool selected) + { + var list = (task.SelectedToolInstanceNames ?? []).ToList(); + if (selected && !list.Contains(name, StringComparer.OrdinalIgnoreCase)) list.Add(name); + else if (!selected) list.RemoveAll(existing => string.Equals(existing, name, StringComparison.OrdinalIgnoreCase)); + task.SelectedToolInstanceNames = list.ToArray(); + } + private IEnumerable> FilteredPromptTemplateGroups => _model.AvailablePromptTemplates .Where(template => string.IsNullOrWhiteSpace(_promptTemplateSearchTerm) || diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs index 5b2b2d3b..16a7729a 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs @@ -266,6 +266,7 @@ public static AIProfileViewModel FromProfile(AIProfile profile) AllowMultipleValues = t.AllowMultipleValues, Options = string.Join(Environment.NewLine, t.Options.Select(o => o.Value)), SelectedToolNames = t.ToolNames ?? [], + SelectedToolInstanceNames = t.ToolInstanceNames ?? [], }).ToList(), EnableUserMemory = memoryMetadata.EnableUserMemory ?? false, @@ -593,6 +594,7 @@ public void ApplyTo(AIProfile profile) .Select(o => new PostSessionTaskOption { Value = o.Trim() }) .ToList(), ToolNames = t.SelectedToolNames ?? [], + ToolInstanceNames = t.SelectedToolInstanceNames ?? [], }).ToList(); }); @@ -682,6 +684,8 @@ public sealed class PostSessionTaskItem public string Options { get; set; } public string[] SelectedToolNames { get; set; } = []; + + public string[] SelectedToolInstanceNames { get; set; } = []; } public sealed class PromptTemplateSelectionItem 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 971dfee7..25038c16 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 @@ -253,6 +253,7 @@ public static AIProfileViewModel FromProfile(AIProfile profile) AllowMultipleValues = t.AllowMultipleValues, Options = string.Join(Environment.NewLine, t.Options.Select(o => o.Value)), SelectedToolNames = t.ToolNames ?? [], + SelectedToolInstanceNames = t.ToolInstanceNames ?? [], }).ToList(), EnableUserMemory = memoryMetadata.EnableUserMemory ?? false, }; @@ -581,6 +582,7 @@ public void ApplyTo(AIProfile profile) .Select(o => new PostSessionTaskOption { Value = o.Trim() }) .ToList(), ToolNames = t.SelectedToolNames ?? [], + ToolInstanceNames = t.SelectedToolInstanceNames ?? [], }).ToList(); }); @@ -671,6 +673,8 @@ public sealed class PostSessionTaskItem public string Options { get; set; } public string[] SelectedToolNames { get; set; } = []; + + public string[] SelectedToolInstanceNames { get; set; } = []; } public sealed class PromptTemplateSelectionItem 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 5458a7ba..1906ec55 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 @@ -1238,7 +1238,8 @@ let taskIndex = 0; const taskCapabilities = { - 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 }) }))) + 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 }) }))), + toolInstances: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableToolInstances.Select(i => new { i.ItemId, i.Name, i.Source }))) }; function buildTaskCapabilitiesHtml(idx) { @@ -1246,6 +1247,9 @@ 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 += `
`; }); }); } + html += `
AI Tool Instances
`; + if (taskCapabilities.toolInstances.length === 0) { html += `

No tool instances are configured. Add them under AI Tool Instances first.

`; } + else { taskCapabilities.toolInstances.forEach(i => { 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 76c206f5..1900b8e7 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 @@ -918,6 +918,26 @@ } } } + +
AI Tool Instances
+ @if (Model.AvailableToolInstances.Count == 0) + { +

No tool instances are configured. Add them under AI Tool Instances first.

+ } + else + { + @foreach (var instance in Model.AvailableToolInstances) + { +
+ + +
+ } + } @@ -1436,7 +1456,8 @@ var taskIndex = @Model.PostSessionTasks.Count; var taskCapabilities = { - 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 }) }))) + 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 }) }))), + toolInstances: @Html.Raw(System.Text.Json.JsonSerializer.Serialize(Model.AvailableToolInstances.Select(i => new { i.ItemId, i.Name, i.Source }))) }; function buildTaskCapabilitiesHtml(idx) { @@ -1444,6 +1465,9 @@ 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 += '
'; }); }); } + html += '
AI Tool Instances
'; + if (taskCapabilities.toolInstances.length === 0) { html += '

No tool instances are configured. Add them under AI Tool Instances first.

'; } + else { taskCapabilities.toolInstances.forEach(function (i) { html += '
'; }); } return html; } diff --git a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs index 7c9627f2..985d6ba3 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs @@ -181,6 +181,102 @@ public async Task ProcessAsync_WithTaskScopedToolNames_ShouldResolveToolsAndUseT mockChatClient.Verify(c => c.GetResponseAsync(It.IsAny>(), It.Is(opts => opts.Tools != null && opts.Tools.Count > 0), It.IsAny()), Times.Once); } + [Fact] + public async Task ProcessAsync_WithOnlyToolInstanceNames_ShouldResolveToolsAndUseToolsPath() + { + // Arrange: profile-level tool instance names alone should enable the shared tools path. + var profile = CreateProfile(); + profile.AlterSettings(s => + { + s.EnablePostSessionProcessing = true; + s.PostSessionTasks = [new PostSessionTask + { + Name = "summary", + Type = PostSessionTaskType.Semantic, + Instructions = "Summarize the conversation.", + }, ]; + s.ToolNames = []; + s.ToolInstanceNames = ["crm-lookup"]; + }); + var session = CreateSession(); + var prompts = CreatePrompts(); + var mockTool = new TestAIFunction("crm-lookup"); + var mockToolRegistry = new Mock(); + AICompletionContext capturedContext = null; + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())) + .Callback((completionContext, _) => capturedContext = completionContext) + .ReturnsAsync(new List { CreateToolEntry("crm-lookup", mockTool) }); + var mockChatClient = new Mock(); + var responseMessage = new ChatMessage(ChatRole.Assistant, "{\"tasks\":[{\"name\":\"summary\",\"value\":\"User asked about pricing and was given options.\"}]}"); + var chatResponse = new ChatResponse(responseMessage); + mockChatClient.Setup(c => c + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(chatResponse); + var mockTemplateService = new Mock(); + mockTemplateService.Setup(t => t + .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync("Rendered prompt"); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); + + // Act + await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); + + // Assert: the instance names were forwarded to the registry. + Assert.NotNull(capturedContext); + Assert.Equal(["crm-lookup"], capturedContext.ToolInstanceNames); + Assert.Empty(capturedContext.ToolNames); + + // 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); + } + + [Fact] + public async Task ProcessAsync_WithTaskLevelToolInstanceNames_ShouldForwardThemToTheRegistry() + { + // Arrange: task-level tool instance names alone should enable the shared tools path. + var profile = CreateProfile(); + profile.AlterSettings(s => + { + s.EnablePostSessionProcessing = true; + s.PostSessionTasks = [new PostSessionTask + { + Name = "summary", + Type = PostSessionTaskType.Semantic, + Instructions = "Summarize the conversation.", + ToolInstanceNames = ["crm-lookup"], + }, ]; + s.ToolNames = []; + s.ToolInstanceNames = []; + }); + var session = CreateSession(); + var prompts = CreatePrompts(); + var mockTool = new TestAIFunction("crm-lookup"); + var mockToolRegistry = new Mock(); + AICompletionContext capturedContext = null; + mockToolRegistry.Setup(t => t.GetAllAsync(It.IsAny(), It.IsAny())) + .Callback((completionContext, _) => capturedContext = completionContext) + .ReturnsAsync(new List { CreateToolEntry("crm-lookup", mockTool) }); + var mockChatClient = new Mock(); + var responseMessage = new ChatMessage(ChatRole.Assistant, "{\"tasks\":[{\"name\":\"summary\",\"value\":\"User asked about pricing and was given options.\"}]}"); + var chatResponse = new ChatResponse(responseMessage); + mockChatClient.Setup(c => c + .GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(chatResponse); + var mockTemplateService = new Mock(); + mockTemplateService.Setup(t => t + .RenderAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync("Rendered prompt"); + var service = CreateService(chatClient: mockChatClient.Object, toolRegistry: mockToolRegistry.Object, templateService: mockTemplateService.Object); + + // Act + await service.ProcessAsync(profile, session, prompts, TestContext.Current.CancellationToken); + + // Assert: the task-level instance names were forwarded to the registry. + Assert.NotNull(capturedContext); + Assert.Equal(["crm-lookup"], capturedContext.ToolInstanceNames); + Assert.Empty(capturedContext.ToolNames); + } + [Fact] public async Task ProcessAsync_WhenToolResponseContainsOnlyInvalidTaskEntriesWithoutToolCalls_ShouldFallBackToNoToolsStructuredPass() {