using AIStudio.Chat; using AIStudio.Provider; using AIStudio.Settings.DataModel; using AIStudio.Tools.ToolCallingSystem; using Microsoft.Extensions.Logging.Abstractions; namespace AIStudio.Tests.Tools.ToolCalling; /// /// Checks that a tool collection the user switched off disappears from every selection and from every request, and comes back when switched on. /// /// /// Somebody without a Confluence wiki never needs its search, and a tool that stays in every /// selection with a warning about its missing settings only gets in the way. Switching it off has /// to hide it everywhere a tool can be picked, and the model must neither get nor call it. The /// settings still list it, or the user could never switch it on again, and selections keep naming /// it, so switching it on brings it back wherever it was selected. /// [TestFixture] [NonParallelizable] public sealed class ToolSwitchedOffByUserTests : ToolRegistryTestBase { private const string OTHER_TOOL_ID = "other_tool"; private const string COLLECTION_ID = "test_collection"; private const string SEARCH_TOOL_ID = "test_search"; private const string READ_TOOL_ID = "test_read"; [Test] public async Task ASwitchedOffToolAppearsInNoSelectionButInTheSettings() { var registry = this.CreateRegistry(new TestTool(Definition()), new TestTool(Definition(OTHER_TOOL_ID))); registry.SetSwitchedOffByUser(TOOL_ID, true); var forTheChat = await registry.GetCatalogAsync(AIStudio.Tools.Components.CHAT); var forTheSettings = await registry.GetCatalogAsync(registry.GetAllDefinitions()); var forItsDialog = await registry.GetCatalogItemAsync(TOOL_ID); Assert.Multiple(() => { Assert.That(forTheChat.Select(item => item.Id), Is.EqualTo(new[] { OTHER_TOOL_ID }), "Every selection is built from this list."); Assert.That(forTheSettings.Single(item => item.Id == TOOL_ID).SwitchOffReason, Is.EqualTo(ToolOfferBlockReason.TOOL_SWITCHED_OFF_BY_USER), "Otherwise, the user could never switch it on again."); Assert.That(forItsDialog?.IsActive, Is.False, "The settings dialog of the tool still opens."); }); } [Test] public async Task AToolTheOrganizationSwitchedOffStaysInTheSelection() { var registry = this.CreateRegistry(new TestTool(Definition())); this.SettingsManager.ConfigurationData.Tools.DisabledToolIds.Add(TOOL_ID); var forTheChat = await registry.GetCatalogAsync(AIStudio.Tools.Components.CHAT); Assert.That(forTheChat.Single().SwitchOffReason, Is.EqualTo(ToolOfferBlockReason.TOOL_SWITCHED_OFF), "Shown locked, so the user learns why the tool does not work."); } [Test] public async Task ASwitchedOffToolIsNeitherCountedNorPreselected() { var registry = this.CreateRegistry(new TestTool(Definition()), new TestTool(Definition(OTHER_TOOL_ID))); registry.SetSwitchedOffByUser(TOOL_ID, true); var counted = registry.FilterToolIdsForProvider(ToolCapableProvider(), [TOOL_ID, OTHER_TOOL_ID], OutboundDataRestriction.UNRESTRICTED); var preselected = await registry.FilterSelectableToolIdsAsync(AIStudio.Tools.Components.CHAT, [TOOL_ID, OTHER_TOOL_ID]); Assert.Multiple(() => { Assert.That(counted, Is.EquivalentTo(new[] { OTHER_TOOL_ID }), "The token count below the message field."); Assert.That(preselected, Is.EquivalentTo(new[] { OTHER_TOOL_ID }), "A chat template or a launcher which names the tool."); }); } [Test] public async Task AModelCannotCallASwitchedOffTool() { var wasCalled = false; var registry = this.CreateRegistry(new TestTool(Definition(), execute: _ => { wasCalled = true; return new ToolExecutionResult(); })); registry.SetSwitchedOffByUser(TOOL_ID, true); var runnableTools = await registry.GetRunnableToolsAsync(this.ContextFor(ToolCapableProvider()), [TOOL_ID], mayRunTools: true); // A model which calls the tool anyway, e.g., because an earlier answer in the chat named it: var executor = new ToolExecutor(this.CreateToolSettingsService(), NullLogger.Instance); var outcome = await executor.ExecuteAsync("call-1", TOOL_ID, "{}", runnableTools, new NoProvider(), new ChatThread(), order: 1); Assert.Multiple(() => { Assert.That(runnableTools, Is.Empty, "The request does not offer the tool."); Assert.That(outcome.Trace.Status, Is.EqualTo(ToolInvocationTraceStatus.BLOCKED)); Assert.That(wasCalled, Is.False); }); } [Test] public async Task ASelectedToolComesBackWhenSwitchedOnAgain() { var registry = this.CreateRegistry(new TestTool(Definition())); string[] selection = [TOOL_ID]; registry.SetSwitchedOffByUser(TOOL_ID, true); var whileSwitchedOff = await registry.GetRunnableToolsAsync(this.ContextFor(ToolCapableProvider()), selection, mayRunTools: true); registry.SetSwitchedOffByUser(TOOL_ID, false); var afterSwitchingOn = await registry.GetRunnableToolsAsync(this.ContextFor(ToolCapableProvider()), selection, mayRunTools: true); Assert.Multiple(() => { Assert.That(whileSwitchedOff, Is.Empty); Assert.That(afterSwitchingOn.Select(tool => tool.Definition.Id), Is.EqualTo(new[] { TOOL_ID }), "The selection kept the tool all along."); Assert.That(this.StoredIds, Is.Empty); }); } [Test] public async Task SwitchingOffOneToolSwitchesOffItsWholeCollection() { var registry = this.CreateCollection(); registry.SetSwitchedOffByUser(READ_TOOL_ID, true); var reason = await registry.GetOfferBlockReasonAsync(SEARCH_TOOL_ID, ToolCapableProvider(), AIStudio.Tools.Components.CHAT); Assert.Multiple(() => { Assert.That(this.StoredIds, Is.EquivalentTo(new[] { COLLECTION_ID }), "The choice belongs to the collection, not to the tool it was made for."); Assert.That(registry.IsSwitchedOffByUser(SEARCH_TOOL_ID), Is.True); Assert.That(reason, Is.EqualTo(ToolOfferBlockReason.TOOL_SWITCHED_OFF_BY_USER)); }); } [Test] public void SwitchingACollectionOnRemovesTheEntriesOfItsTools() { var registry = this.CreateCollection(); this.StoredIds.Add(READ_TOOL_ID); var whileSwitchedOff = registry.IsToolActive(SEARCH_TOOL_ID); registry.SetSwitchedOffByUser(COLLECTION_ID, false); Assert.Multiple(() => { Assert.That(whileSwitchedOff, Is.False, "An entry for one tool counts for its whole collection."); Assert.That(registry.IsToolActive(SEARCH_TOOL_ID), Is.True, "The old entry of the reader would keep the collection switched off."); Assert.That(this.StoredIds, Is.Empty); }); } [Test] public void WhatTheUserSwitchedOffStaysOffWhenTheOrganizationLetsGo() { var registry = this.CreateRegistry(new TestTool(Definition())); registry.SetSwitchedOffByUser(TOOL_ID, true); this.SettingsManager.ConfigurationData.Tools.DisabledToolIds.Add(TOOL_ID); var whileBothApply = registry.IsSwitchedOffByUser(TOOL_ID); this.SettingsManager.ConfigurationData.Tools.DisabledToolIds.Remove(TOOL_ID); Assert.Multiple(() => { Assert.That(whileBothApply, Is.True); Assert.That(registry.IsToolActive(TOOL_ID), Is.False); }); } [Test] public void ASelectionFieldShowsNoSwitchedOffTool() { var registry = this.CreateCollection(); registry.SetSwitchedOffByUser(COLLECTION_ID, true); var shown = registry.LeaveOutSwitchedOffByUser([READ_TOOL_ID, TOOL_ID]); Assert.That(shown, Is.EquivalentTo(new[] { TOOL_ID }), "The field lists no switched-off tool, so it must not count one as selected either."); } [Test] public void ChangingASelectionKeepsTheToolsSwitchedOff() { var registry = this.CreateCollection(); registry.SetSwitchedOffByUser(COLLECTION_ID, true); // The field showed only the other tool, and the user took it out: var stored = registry.KeepSwitchedOffByUser([READ_TOOL_ID, TOOL_ID], []); Assert.That(stored, Is.EquivalentTo(new[] { COLLECTION_ID }), "Switching the collection on again brings it back in the template."); } [Test] public void ChangingASelectionDropsWhatTheUserDeselected() { var registry = this.CreateCollection(); var stored = registry.KeepSwitchedOffByUser([READ_TOOL_ID, TOOL_ID], [TOOL_ID]); Assert.That(stored, Is.EquivalentTo(new[] { TOOL_ID }), "Nothing is switched off, so nothing comes back on its own."); } private HashSet StoredIds => this.SettingsManager.ConfigurationData.Tools.UserDisabledToolIds; /// /// A search and a reader in one collection, next to a tool of its own. /// private ToolRegistry CreateCollection() => this.CreateRegistry( [new TestCollection(COLLECTION_ID, ConfidenceLevel.NONE, SEARCH_TOOL_ID, READ_TOOL_ID)], new TestTool(Definition(SEARCH_TOOL_ID)), new TestTool(Definition(READ_TOOL_ID)), new TestTool(Definition())); }