diff --git a/build/props/DynamicData.props b/build/props/DynamicData.props
index f8a5d773..54af9e59 100644
--- a/build/props/DynamicData.props
+++ b/build/props/DynamicData.props
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/build/props/Microsoft.Extensions.AI.Abstractions.props b/build/props/Microsoft.Extensions.AI.Abstractions.props
index 566d03d3..9ca9b56c 100644
--- a/build/props/Microsoft.Extensions.AI.Abstractions.props
+++ b/build/props/Microsoft.Extensions.AI.Abstractions.props
@@ -1,6 +1,6 @@
-
+
diff --git a/build/props/Microsoft.Extensions.DependencyInjection.Abstractions.props b/build/props/Microsoft.Extensions.DependencyInjection.Abstractions.props
index 8dad37c7..cf7fb14b 100644
--- a/build/props/Microsoft.Extensions.DependencyInjection.Abstractions.props
+++ b/build/props/Microsoft.Extensions.DependencyInjection.Abstractions.props
@@ -1,6 +1,6 @@
-
+
diff --git a/build/props/Microsoft.Extensions.DependencyInjection.props b/build/props/Microsoft.Extensions.DependencyInjection.props
index 04213c16..70629292 100644
--- a/build/props/Microsoft.Extensions.DependencyInjection.props
+++ b/build/props/Microsoft.Extensions.DependencyInjection.props
@@ -1,6 +1,6 @@
-
+
diff --git a/build/props/Microsoft.Extensions.Logging.Abstractions.props b/build/props/Microsoft.Extensions.Logging.Abstractions.props
index 71a170f4..c19f3da5 100644
--- a/build/props/Microsoft.Extensions.Logging.Abstractions.props
+++ b/build/props/Microsoft.Extensions.Logging.Abstractions.props
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/build/props/Microsoft.Extensions.Logging.props b/build/props/Microsoft.Extensions.Logging.props
index 16667d42..cd7e96da 100644
--- a/build/props/Microsoft.Extensions.Logging.props
+++ b/build/props/Microsoft.Extensions.Logging.props
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/build/props/SharpCompress.props b/build/props/SharpCompress.props
index b59516c4..cd86ffd1 100644
--- a/build/props/SharpCompress.props
+++ b/build/props/SharpCompress.props
@@ -1,6 +1,6 @@
-
+
diff --git a/build/props/XUnit.props b/build/props/XUnit.props
index e522139f..2de65bbe 100644
--- a/build/props/XUnit.props
+++ b/build/props/XUnit.props
@@ -9,6 +9,6 @@
-
+
\ No newline at end of file
diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md
index 2e0f7f9b..56d02cac 100644
--- a/docs/PluginDevelopment.md
+++ b/docs/PluginDevelopment.md
@@ -387,11 +387,13 @@ Provides all app path locations: `AppDataDirectory`, `ProjectsDirectory`, `Packa
#### `IAiFunctionProvider` (src/OneWare.Essentials/Services/IAiFunctionProvider.cs)
-- `RegisterFunction(IOneWareAiFunction)`: register an AI tool.
+- `RegisterFunction(IOneWareAiFunction)`: register an AI tool. Tools registered while chats are open are
+ pushed to their live sessions too.
- `RegisterAgent(OneWareAiAgent)`: register a custom agent (see "AI agents and skills").
- `RegisterSkill(OneWareAiSkill)`: register an on-demand skill document.
- `RegisterSkillDirectory(string)`: register a directory of skills shipped with the plugin.
- `GetTools()`: return the registered tools for chat or automation.
+- `ToolsChanged` event: raised when the result of `GetTools()` changes.
- `FunctionStarted`, `FunctionProgress`, `FunctionCompleted` events identify each concurrent
invocation by its unique ID.
- Set `OneWareAiFunction.IsReadOnly` on tools that only read state, so read-only chat agents
diff --git a/src/OneWare.Chat/Services/AiFunctionProvider.cs b/src/OneWare.Chat/Services/AiFunctionProvider.cs
index 24953d5c..0ddb6f66 100644
--- a/src/OneWare.Chat/Services/AiFunctionProvider.cs
+++ b/src/OneWare.Chat/Services/AiFunctionProvider.cs
@@ -32,6 +32,7 @@ public class AiFunctionProvider(
public event EventHandler? FunctionStarted;
public event EventHandler? FunctionCompleted;
public event EventHandler? FunctionProgress;
+ public event EventHandler? ToolsChanged;
public void RegisterFunction(IOneWareAiFunction function)
{
@@ -42,6 +43,8 @@ public void RegisterFunction(IOneWareAiFunction function)
_registeredFunctions.RemoveAll(x => string.Equals(x.Name, function.Name, StringComparison.Ordinal));
_registeredFunctions.Add(function);
}
+
+ ToolsChanged?.Invoke(this, EventArgs.Empty);
}
public void RegisterPromptAddition(string promptAddition)
diff --git a/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs b/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs
index 8d3ab525..78f7cdbe 100644
--- a/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs
+++ b/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs
@@ -249,7 +249,8 @@ private async Task ActivateAsync()
if (!string.Equals(sessions.CurrentSessionId, targetSessionId, StringComparison.Ordinal))
await sessions.LoadSessionAsync(targetSessionId);
IsActivated = true;
- ShowReplacementSessionIfNeeded(targetSessionId);
+ if (ShowReplacementSessionIfNeeded(targetSessionId))
+ AddErrorMessage("The previous session could not be restored, so this chat continues in a new session. The original chat stays in the history.");
return true;
}
@@ -540,13 +541,13 @@ private void ReleaseConnectionWaiters(bool connected)
/// A session that no longer exists in the backend is replaced by a new one; the transcript of
/// the requested session must not stay visible, otherwise it would be saved into the new session.
///
- private void ShowReplacementSessionIfNeeded(string requestedSessionId)
+ private bool ShowReplacementSessionIfNeeded(string requestedSessionId)
{
if (Service is not IChatServiceWithSessions sessions ||
string.IsNullOrWhiteSpace(sessions.CurrentSessionId) ||
string.Equals(sessions.CurrentSessionId, requestedSessionId, StringComparison.Ordinal))
{
- return;
+ return false;
}
Messages.Clear();
@@ -557,6 +558,7 @@ private void ShowReplacementSessionIfNeeded(string requestedSessionId)
_pendingSubAgentTools.Clear();
_host.OnSessionChanged(this);
+ return true;
}
private async Task NewChatAsync()
diff --git a/src/OneWare.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs
index c02573d9..0efa14ed 100644
--- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs
+++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs
@@ -2,6 +2,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
+using Avalonia.Controls.Notifications;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input;
using OneWare.Chat.Services;
@@ -27,6 +28,15 @@ public class ChatViewModel : ExtendedTool, IChatManagerService, IChatSessionHost
public const int DefaultMaxSessionHistory = 50;
+ /// Service that opens stored chats whose own service is no longer registered.
+ public const string LegacyFallbackServiceName = "GitHub Copilot";
+
+ /// Former names of chat services, mapped to their current name.
+ private static readonly Dictionary LegacyServiceNames = new(StringComparer.Ordinal)
+ {
+ ["OneWare Cloud"] = "OneWare Agents"
+ };
+
private readonly IMainDockService _mainDockService;
private readonly IAiFunctionProvider _aiFunctionProvider;
private readonly ISettingsService _settingsService;
@@ -430,15 +440,107 @@ private static async Task DisposeInstanceAsync(IChatService instance)
(string.Equals(t.CurrentSessionId, sessionId, StringComparison.Ordinal) ||
string.Equals(t.RememberedSessionId, sessionId, StringComparison.Ordinal)));
- private async Task OpenOverviewItemAsync(ChatOverviewItem item)
+ private IChatService? FindService(string serviceName) =>
+ ChatServices.FirstOrDefault(s => string.Equals(s.Name, serviceName, StringComparison.Ordinal));
+
+ private static void ReportOpenFailure(string chatName, string reason, Exception? exception = null)
{
- if (item.Session != null && _sessions.Contains(item.Session))
+ ContainerLocator.Container.Resolve()
+ ?.Warning($"Opening chat '{chatName}' failed: {reason}", exception);
+
+ try
{
- Show(item.Session);
- return;
+ ContainerLocator.Container.Resolve()
+ .ShowNotification("Chat could not be opened", $"{chatName}: {reason}", NotificationType.Warning);
+ }
+ catch
+ {
+ // No window service (e.g. in tests); the warning above is logged.
+ }
+ }
+
+ ///
+ /// Moves a stored chat to the history of another service, so it is saved, listed and pruned there from now on.
+ ///
+ private ChatSessionHistoryItem MoveHistoryItem(ChatSessionHistoryItem item, string serviceName)
+ {
+ var targetDirectory = GetServiceHistoryDirectory(serviceName);
+ var targetPath = Path.Combine(targetDirectory, Path.GetFileName(item.FilePath));
+
+ try
+ {
+ ChatSessionFile? file;
+ using (var stream = File.OpenRead(item.FilePath))
+ file = JsonSerializer.Deserialize(stream, ChatStateSerializerOptions);
+ if (file == null) throw new InvalidDataException("The chat file is empty.");
+
+ file.ServiceName = serviceName;
+ Directory.CreateDirectory(targetDirectory);
+ using (var stream = File.Open(targetPath, FileMode.Create, FileAccess.Write, FileShare.None))
+ JsonSerializer.Serialize(stream, file, ChatStateSerializerOptions);
+
+ if (!string.Equals(Path.GetFullPath(targetPath), Path.GetFullPath(item.FilePath), StringComparison.Ordinal))
+ File.Delete(item.FilePath);
+ }
+ catch (Exception e)
+ {
+ ContainerLocator.Container.Resolve()
+ ?.Warning($"Moving chat '{item.Name}' to {serviceName} failed", e);
+ // Keep the original file; the chat is saved to the new service on its next transcript store.
+ targetPath = item.FilePath;
+ }
+
+ if (_historyByService.TryGetValue(item.ServiceName, out var oldItems))
+ {
+ oldItems.Remove(item);
+ if (oldItems.Count == 0) _historyByService.Remove(item.ServiceName);
+ }
+
+ var moved = new ChatSessionHistoryItem
+ {
+ ServiceName = serviceName,
+ SessionId = item.SessionId,
+ Name = item.Name,
+ UpdatedAt = item.UpdatedAt,
+ FilePath = targetPath
+ };
+
+ if (!_historyByService.TryGetValue(serviceName, out var items))
+ {
+ items = [];
+ _historyByService[serviceName] = items;
}
- if (item.HistoryItem != null) await OpenHistoryItemAsync(item.HistoryItem);
+ items.RemoveAll(x => string.Equals(x.SessionId, moved.SessionId, StringComparison.Ordinal));
+ items.Add(moved);
+ items.Sort((a, b) => b.UpdatedAt.CompareTo(a.UpdatedAt));
+ RefreshOverview();
+ return moved;
+ }
+
+ private async Task OpenOverviewItemAsync(ChatOverviewItem item)
+ {
+ try
+ {
+ if (item.Session != null && _sessions.Contains(item.Session))
+ {
+ Show(item.Session);
+ return;
+ }
+
+ if (item.HistoryItem != null)
+ {
+ await OpenHistoryItemAsync(item.HistoryItem);
+ return;
+ }
+
+ ReportOpenFailure(item.Title, "The chat is no longer available.");
+ RefreshOverview();
+ }
+ catch (Exception e)
+ {
+ ReportOpenFailure(item.Title, e.Message, e);
+ }
}
private async Task OpenHistoryItemAsync(ChatSessionHistoryItem item)
@@ -449,10 +551,32 @@ private async Task OpenHistoryItemAsync(ChatSessionHistoryItem item)
return;
}
- var service = ChatServices.FirstOrDefault(s => string.Equals(s.Name, item.ServiceName, StringComparison.Ordinal));
- if (service == null) return;
+ var service = FindService(item.ServiceName);
+ if (service == null)
+ {
+ // Chats of renamed services continue in their new service, chats of removed ones in GitHub Copilot.
+ service = (LegacyServiceNames.TryGetValue(item.ServiceName, out var renamed) ? FindService(renamed) : null)
+ ?? FindService(LegacyFallbackServiceName);
+ if (service == null)
+ {
+ ReportOpenFailure(item.Name,
+ $"The chat service '{item.ServiceName}' is not available and there is no fallback service.");
+ return;
+ }
+
+ ContainerLocator.Container.Resolve()
+ ?.Warning($"Chat service '{item.ServiceName}' is not available, opening chat '{item.Name}' with {service.Name}");
+ item = MoveHistoryItem(item, service.Name);
- TryReadSessionMessages(item, out var states);
+ if (FindLoaded(item.ServiceName, item.SessionId) is { } moved)
+ {
+ Show(moved);
+ return;
+ }
+ }
+
+ if (!TryReadSessionMessages(item, out var states))
+ ReportOpenFailure(item.Name, "The stored messages of this chat could not be read.");
LeaveCurrent();
var session = CreateSession(service, item.SessionId, states, item.Name, out var blocking);
@@ -1085,8 +1209,10 @@ private static bool TryReadSessionMessages(ChatSessionHistoryItem item, out List
messages = file.Messages;
return true;
}
- catch
+ catch (Exception e)
{
+ ContainerLocator.Container.Resolve()
+ ?.Warning($"Reading chat '{item.Name}' failed", e);
return false;
}
}
diff --git a/src/OneWare.CloudIntegration/OneWare.CloudIntegration.csproj b/src/OneWare.CloudIntegration/OneWare.CloudIntegration.csproj
index 71197118..89f4ef04 100644
--- a/src/OneWare.CloudIntegration/OneWare.CloudIntegration.csproj
+++ b/src/OneWare.CloudIntegration/OneWare.CloudIntegration.csproj
@@ -9,7 +9,7 @@
-
-
+
+
\ No newline at end of file
diff --git a/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs
index 6b797093..89e93bc2 100644
--- a/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs
+++ b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs
@@ -330,6 +330,10 @@ private async Task SaveCredentialsAsync(string jwt, string refreshToken)
// The platform credential store can be slow (keyring/dbus, DPAPI), keep it off the UI thread
await Task.Run(() => StoreRefreshToken(userId, refreshToken));
+ // Tokens are refreshed every few minutes; only write the settings file when the account changed.
+ if (_settingService.GetSettingValue(OneWareCloudIntegrationModule.OneWareAccountUserIdKey) == userId)
+ return;
+
_settingService.SetSettingValue(OneWareCloudIntegrationModule.OneWareAccountUserIdKey, userId);
_settingService.Save(_paths.SettingsPath);
}
diff --git a/src/OneWare.Copilot/CopilotModule.cs b/src/OneWare.Copilot/CopilotModule.cs
index e728b200..bc0729e3 100644
--- a/src/OneWare.Copilot/CopilotModule.cs
+++ b/src/OneWare.Copilot/CopilotModule.cs
@@ -56,7 +56,7 @@ public class CopilotModule : OneWareModuleBase
Id = "copilotcli",
Type = "NativeTool",
Name = "Copilot CLI",
- Description = "Used for Copilot Integration",
+ Description = "Agent runtime used by the GitHub Copilot and OneWare Agents chats. OneWare Agents doesn't need a GitHub account.",
License = "GitHub Copilot CLI License",
IconUrl = "https://github.githubassets.com/images/modules/site/copilot/copilot.png",
AcceptLicenseBeforeDownload = true,
@@ -90,38 +90,38 @@ public class CopilotModule : OneWareModuleBase
[
new PackageVersion()
{
- Version = "1.0.83",
+ Version = "1.0.97",
Targets =
[
new PackageTarget()
{
Target = "win-x64",
- Url = "https://github.com/github/copilot-cli/releases/download/v1.0.83/copilot-win32-x64.zip"
+ Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-win32-x64.zip"
},
new PackageTarget()
{
Target = "win-arm64",
- Url = "https://github.com/github/copilot-cli/releases/download/v1.0.83/copilot-win32-arm64.zip"
+ Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-win32-arm64.zip"
},
new PackageTarget()
{
Target = "linux-x64",
- Url = "https://github.com/github/copilot-cli/releases/download/v1.0.83/copilot-linux-x64.tar.gz"
+ Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-linux-x64.tar.gz"
},
new PackageTarget()
{
Target = "linux-arm64",
- Url = "https://github.com/github/copilot-cli/releases/download/v1.0.83/copilot-linux-arm64.tar.gz"
+ Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-linux-arm64.tar.gz"
},
new PackageTarget()
{
Target = "osx-x64",
- Url = "https://github.com/github/copilot-cli/releases/download/v1.0.83/copilot-darwin-x64.tar.gz"
+ Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-darwin-x64.tar.gz"
},
new PackageTarget()
{
Target = "osx-arm64",
- Url = "https://github.com/github/copilot-cli/releases/download/v1.0.83/copilot-darwin-arm64.tar.gz"
+ Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-darwin-arm64.tar.gz"
},
]
}
@@ -255,6 +255,7 @@ public override void Initialize(IServiceProvider serviceProvider)
new object[]
{
CopilotChatService.AutoTierDefault,
+ CopilotChatService.AutoTierFast,
CopilotChatService.AutoTierEfficiency,
CopilotChatService.AutoTierBalance,
CopilotChatService.AutoTierIntelligence
@@ -262,8 +263,8 @@ public override void Initialize(IServiceProvider serviceProvider)
{
HoverDescription =
"Routing preference for the \"auto\" model, which lets Copilot pick a backend " +
- "model per turn. Default: leave the choice to Copilot. Efficiency: prefer " +
- "faster, cheaper models. Balance: trade off speed and capability. " +
+ "model per turn. Default: leave the choice to Copilot. Fast: prefer the " +
+ "lowest-latency models. Efficiency: prefer cheaper models. Balance: trade off speed and capability. " +
"Intelligence: prefer the most capable models."
});
diff --git a/src/OneWare.Copilot/OneWare.Copilot.csproj b/src/OneWare.Copilot/OneWare.Copilot.csproj
index 9674653c..412f7d9e 100644
--- a/src/OneWare.Copilot/OneWare.Copilot.csproj
+++ b/src/OneWare.Copilot/OneWare.Copilot.csproj
@@ -17,7 +17,7 @@
-
+
\ No newline at end of file
diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs
index 7472cc23..d0d681ed 100644
--- a/src/OneWare.Copilot/Services/CopilotChatService.cs
+++ b/src/OneWare.Copilot/Services/CopilotChatService.cs
@@ -671,6 +671,7 @@ private void ApplyContextTierToSession()
}
public const string AutoTierDefault = "Default";
+ public const string AutoTierFast = "Fast";
public const string AutoTierEfficiency = "Efficiency";
public const string AutoTierBalance = "Balance";
public const string AutoTierIntelligence = "Intelligence";
@@ -680,7 +681,7 @@ private void ApplyContextTierToSession()
/// choice back to the provider.
///
public ObservableCollection AutoTiers { get; } =
- [AutoTierDefault, AutoTierEfficiency, AutoTierBalance, AutoTierIntelligence];
+ [AutoTierDefault, AutoTierFast, AutoTierEfficiency, AutoTierBalance, AutoTierIntelligence];
///
/// True while the auto model is selected — only then does a routing preference apply.
@@ -737,6 +738,7 @@ private void RefreshAutoTier(ModelInfo model)
private AutoTier? ResolveAutoTier() => SelectedAutoTier switch
{
+ AutoTierFast => AutoTier.Fast,
AutoTierEfficiency => AutoTier.Efficiency,
AutoTierBalance => AutoTier.Balance,
AutoTierIntelligence => AutoTier.Intelligence,
@@ -746,6 +748,7 @@ private void RefreshAutoTier(ModelInfo model)
private static string DescribeAutoTier(AutoTier? tier) => tier?.Value switch
{
null => AutoTierDefault,
+ var value when value == AutoTier.Fast.Value => AutoTierFast,
var value when value == AutoTier.Efficiency.Value => AutoTierEfficiency,
var value when value == AutoTier.Balance.Value => AutoTierBalance,
var value when value == AutoTier.Intelligence.Value => AutoTierIntelligence,
@@ -1390,6 +1393,14 @@ public bool TryAddImageAttachment(byte[] data, string mimeType, string displayNa
public event EventHandler? EventReceived;
public event EventHandler? StatusChanged;
+ ///
+ /// Chats with their own model provider (e.g. OneWare Agents) still run on the Copilot CLI, which looks
+ /// like it needs GitHub. Explains that it is only the local agent runtime there.
+ ///
+ private string GetCliRuntimeNote() => _byokConfiguration == null
+ ? string.Empty
+ : $" {Name} only uses it as the local agent runtime, so no GitHub account or Copilot subscription is needed.";
+
private async Task InstallCopilotCLiAsync(Control? owner, bool update = false)
{
var cliPath = CopilotModule.GetCliPath(paths);
@@ -1561,7 +1572,7 @@ public async Task InitializeAsync()
{
StatusChanged?.Invoke(this, new StatusEvent(false, "CLI Not found"));
Blocker = new ChatServiceBlocker("Copilot CLI required",
- $"{Name} needs the Copilot CLI. Install it to start chatting.")
+ $"{Name} needs the Copilot CLI. Install it to start chatting.{GetCliRuntimeNote()}")
{
ActionText = "Install Copilot CLI",
ActionCommand = new AsyncRelayCommand(x => InstallCopilotCLiAsync(x))
@@ -1569,15 +1580,15 @@ public async Task InitializeAsync()
return false;
}
- if (_byokConfiguration == null &&
- (packageService.IsLoaded || await packageService.RefreshAsync(false)))
+ // The SDK only works reliably with the CLI version it was built for, so this applies to BYOK chats too.
+ if (packageService.IsLoaded || await packageService.RefreshAsync(false))
{
if (packageService.Packages.TryGetValue(CopilotModule.CopilotPackage.Id!, out var state) &&
state.Status is PackageStatus.UpdateAvailable)
{
StatusChanged?.Invoke(this, new StatusEvent(false, "CLI Update Available"));
Blocker = new ChatServiceBlocker("Copilot CLI update available",
- "Update the Copilot CLI to continue chatting.")
+ $"Update the Copilot CLI to continue chatting.{GetCliRuntimeNote()}")
{
ActionText = "Update Copilot CLI",
ActionCommand = new AsyncRelayCommand(x => InstallCopilotCLiAsync(x, true))
@@ -2096,6 +2107,12 @@ private async Task InitializeSessionCoreAsync(string? sessionId)
_subscription = _session.On(HandleSessionEvent);
+ if (!_toolsChangedSubscribed)
+ {
+ toolProvider.ToolsChanged += OnToolsChanged;
+ _toolsChangedSubscribed = true;
+ }
+
if (string.IsNullOrWhiteSpace(sessionId))
{
// The routing preference is not part of the session config, so it has to be applied to
@@ -2405,6 +2422,13 @@ public async ValueTask DisposeAsync()
{
var isSibling = !IsConnectionOwner;
+ if (_toolsChangedSubscribed)
+ {
+ toolProvider.ToolsChanged -= OnToolsChanged;
+ _toolsChangedSubscribed = false;
+ }
+ Interlocked.Exchange(ref _toolsRefreshCts, null)?.Cancel();
+
ReleasePendingInputRequests();
ReleasePendingPlanRequests();
@@ -2837,6 +2861,56 @@ private void RememberSubAgentInstructions(ToolExecutionStartData data)
///
private HashSet? _clientToolNames;
+ private bool _toolsChangedSubscribed;
+ private CancellationTokenSource? _toolsRefreshCts;
+
+ ///
+ /// Tools are captured when a session is created or resumed, so tools registered later (e.g. by a plugin)
+ /// are pushed to the live session. Bursts of registrations are coalesced into one update.
+ ///
+ private void OnToolsChanged(object? sender, EventArgs e)
+ {
+ _clientToolNames = null;
+ if (_session == null) return;
+
+ var cts = new CancellationTokenSource();
+ Interlocked.Exchange(ref _toolsRefreshCts, cts)?.Cancel();
+ _ = RefreshSessionToolsAsync(cts.Token);
+ }
+
+ private async Task RefreshSessionToolsAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken);
+
+ // Session creation reads the current tools itself, so it must not interleave with an update.
+ await _initGate.WaitAsync(cancellationToken);
+ try
+ {
+ var session = _session;
+ if (session == null) return;
+
+ _clientToolNames = null;
+ var tools = toolProvider.GetTools().Cast().ToList();
+ await session.SetToolsAsync(tools, cancellationToken);
+ }
+ finally
+ {
+ _initGate.Release();
+ }
+ }
+ catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
+ {
+ // Superseded by a newer update, or the session was closed meanwhile.
+ }
+ catch (Exception ex)
+ {
+ ContainerLocator.Container.Resolve()
+ .LogWarning(ex, "Failed to update the tools of the Copilot session.");
+ }
+ }
+
private bool IsClientTool(string? toolName)
{
if (string.IsNullOrWhiteSpace(toolName)) return false;
diff --git a/src/OneWare.Core/OneWare.Core.csproj b/src/OneWare.Core/OneWare.Core.csproj
index 45b335fa..902339b4 100644
--- a/src/OneWare.Core/OneWare.Core.csproj
+++ b/src/OneWare.Core/OneWare.Core.csproj
@@ -21,7 +21,7 @@
-
+
diff --git a/src/OneWare.Essentials/Services/IAiFunctionProvider.cs b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs
index 2db8b520..2895b5b9 100644
--- a/src/OneWare.Essentials/Services/IAiFunctionProvider.cs
+++ b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs
@@ -16,6 +16,16 @@ event EventHandler? FunctionProgress
remove { }
}
+ ///
+ /// Fired when the set of tools returned by changes, e.g. when a plugin registers a
+ /// function after chat sessions were created. Chat services use it to update their live sessions.
+ ///
+ event EventHandler? ToolsChanged
+ {
+ add { }
+ remove { }
+ }
+
/// Returns available AI tools for this provider.
ICollection GetTools();
diff --git a/src/OneWare.PackageManager/Services/PackageService.cs b/src/OneWare.PackageManager/Services/PackageService.cs
index 88dac5cd..89c1f94f 100644
--- a/src/OneWare.PackageManager/Services/PackageService.cs
+++ b/src/OneWare.PackageManager/Services/PackageService.cs
@@ -37,6 +37,9 @@ public class PackageService : ObservableObject, IPackageService, IDisposable
private readonly List _repositoryUrls = [];
private bool _disposed;
+
+ // Package source settings used by the last refresh, see OnSettingsSaved.
+ private volatile string? _loadedSourcesSignature;
private Task? _currentRefreshTask;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
@@ -433,6 +436,7 @@ private async Task RefreshInternalAsync()
.ToList();
var onlyCustomSources = _settingsService.GetSettingValue("PackageManager_OnlyCustomSources");
+ _loadedSourcesSignature = GetSourcesSignature();
var allRepos = onlyCustomSources
? customRepositories
: _repositoryUrls.Concat(customRepositories).ToList();
@@ -664,7 +668,21 @@ private async Task DownloadAndInstallAsync(PackageState st
private void OnSettingsSaved(object? sender, EventArgs e)
{
- _ = RefreshAsync(false);
+ // Settings are saved for many unrelated reasons (e.g. on every OneWare Cloud token refresh), so only
+ // reload the package sources when the source settings changed since the last refresh. Before the
+ // first refresh there is nothing to update, the startup refresh reads the current settings anyway.
+ var loaded = _loadedSourcesSignature;
+ if (loaded == null || loaded == GetSourcesSignature()) return;
+
+ // A refresh that is already running may still use the old sources.
+ _ = RefreshAsync(true);
+ }
+
+ private string GetSourcesSignature()
+ {
+ var sources = _settingsService.GetSettingValue>("PackageManager_Sources");
+ var onlyCustomSources = _settingsService.GetSettingValue("PackageManager_OnlyCustomSources");
+ return $"{onlyCustomSources}\n{string.Join('\n', sources ?? [])}";
}
private void UpdateStatus(PackageState state)
diff --git a/src/OneWare.SerialMonitor/OneWare.SerialMonitor.csproj b/src/OneWare.SerialMonitor/OneWare.SerialMonitor.csproj
index 84a08ace..36e9ce0f 100644
--- a/src/OneWare.SerialMonitor/OneWare.SerialMonitor.csproj
+++ b/src/OneWare.SerialMonitor/OneWare.SerialMonitor.csproj
@@ -8,6 +8,6 @@
-
+
\ No newline at end of file
diff --git a/tests/OneWare.Chat.UnitTests/AiFunctionProviderTests.cs b/tests/OneWare.Chat.UnitTests/AiFunctionProviderTests.cs
new file mode 100644
index 00000000..a47c0413
--- /dev/null
+++ b/tests/OneWare.Chat.UnitTests/AiFunctionProviderTests.cs
@@ -0,0 +1,26 @@
+using OneWare.Chat.Services;
+using OneWare.Essentials.Models;
+using Xunit;
+
+namespace OneWare.Chat.UnitTests;
+
+public class AiFunctionProviderTests
+{
+ [Fact]
+ public void RegisterFunction_RaisesToolsChanged()
+ {
+ // The services are only needed to register the built-in functions, which this test doesn't trigger.
+ var provider = new AiFunctionProvider(null!, null!, null!, null!, null!, null!, null!, null!);
+ var raised = 0;
+ provider.ToolsChanged += (_, _) => raised++;
+
+ provider.RegisterFunction(new OneWareAiFunction
+ {
+ Name = "pluginTool",
+ Description = "A tool registered by a plugin",
+ Handler = () => "ok"
+ });
+
+ Assert.Equal(1, raised);
+ }
+}
diff --git a/tests/OneWare.PackageManager.UnitTests/PackageServiceTests.cs b/tests/OneWare.PackageManager.UnitTests/PackageServiceTests.cs
index 7d369882..e29099a7 100644
--- a/tests/OneWare.PackageManager.UnitTests/PackageServiceTests.cs
+++ b/tests/OneWare.PackageManager.UnitTests/PackageServiceTests.cs
@@ -227,6 +227,42 @@ public async Task RefreshAsync_OffersStableUpdateOnStableChannel()
Assert.Equal("1.1.0", state.ResolveTargetVersion()?.Version);
}
+ [Fact]
+ public async Task SettingsSaved_DoesNotRefreshWhenPackageSourcesAreUnchanged()
+ {
+ SetInstalled();
+ _catalog.Manifests.Returns(new Dictionary());
+ await _service.RefreshAsync(true);
+ _catalog.ClearReceivedCalls();
+
+ _settingsService.Saved += Raise.EventWith(_settingsService, new SaveEventArgs(true));
+ await Task.Delay(100);
+
+ await _catalog.DidNotReceive().RefreshAsync(Arg.Any>(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task SettingsSaved_RefreshesWhenPackageSourcesChanged()
+ {
+ SetInstalled();
+ _catalog.Manifests.Returns(new Dictionary());
+ await _service.RefreshAsync(true);
+ _catalog.ClearReceivedCalls();
+
+ _settingsService.GetSettingValue>(
+ "PackageManager_Sources").Returns(["https://example.com/packages.json"]);
+ _settingsService.Saved += Raise.EventWith(_settingsService, new SaveEventArgs(true));
+
+ var deadline = DateTime.UtcNow.AddSeconds(5);
+ while (_catalog.ReceivedCalls().All(x => x.GetMethodInfo().Name != nameof(IPackageCatalog.RefreshAsync)) &&
+ DateTime.UtcNow < deadline)
+ await Task.Delay(20);
+
+ await _catalog.Received(1).RefreshAsync(
+ Arg.Is>(x => x.Any(r => r.Contains("https://example.com/packages.json"))),
+ Arg.Any());
+ }
+
private void SetInstalled(params InstalledPackage[] packages)
{
_stateStore.LoadAsync().Returns(packages.ToDictionary(x => x.Id));