From 53c77bcb88272338feecc9ff195408c265db53ac Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Wed, 14 Oct 2026 12:47:29 +0200 Subject: [PATCH 1/6] Update NuGet packages without breaking plugin compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/props/DynamicData.props | 2 +- build/props/Microsoft.Extensions.AI.Abstractions.props | 2 +- ...icrosoft.Extensions.DependencyInjection.Abstractions.props | 2 +- build/props/Microsoft.Extensions.DependencyInjection.props | 2 +- build/props/Microsoft.Extensions.Logging.Abstractions.props | 2 +- build/props/Microsoft.Extensions.Logging.props | 2 +- build/props/SharpCompress.props | 2 +- build/props/XUnit.props | 2 +- src/OneWare.CloudIntegration/OneWare.CloudIntegration.csproj | 4 ++-- src/OneWare.Core/OneWare.Core.csproj | 2 +- src/OneWare.SerialMonitor/OneWare.SerialMonitor.csproj | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build/props/DynamicData.props b/build/props/DynamicData.props index f8a5d7732..54af9e59d 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 566d03d32..9ca9b56c9 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 8dad37c76..cf7fb14b5 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 04213c16b..70629292a 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 71a170f49..c19f3da5d 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 16667d42d..cd7e96da5 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 b59516c41..cd86ffd1b 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 e522139f5..2de65bbe5 100644 --- a/build/props/XUnit.props +++ b/build/props/XUnit.props @@ -9,6 +9,6 @@ - + \ No newline at end of file diff --git a/src/OneWare.CloudIntegration/OneWare.CloudIntegration.csproj b/src/OneWare.CloudIntegration/OneWare.CloudIntegration.csproj index 711971189..89f4ef049 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.Core/OneWare.Core.csproj b/src/OneWare.Core/OneWare.Core.csproj index 45b335fac..902339b4b 100644 --- a/src/OneWare.Core/OneWare.Core.csproj +++ b/src/OneWare.Core/OneWare.Core.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/OneWare.SerialMonitor/OneWare.SerialMonitor.csproj b/src/OneWare.SerialMonitor/OneWare.SerialMonitor.csproj index 84a08ace4..36e9ce0f4 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 From 2a62b56ab0b05a3e81677297272fde447fe7ccb4 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Wed, 14 Oct 2026 12:47:29 +0200 Subject: [PATCH 2/6] Update Copilot SDK to 1.0.21, add Fast auto tier and live tool updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/PluginDevelopment.md | 4 +- .../Services/AiFunctionProvider.cs | 3 + src/OneWare.Copilot/CopilotModule.cs | 19 +++--- src/OneWare.Copilot/OneWare.Copilot.csproj | 2 +- .../Services/CopilotChatService.cs | 68 ++++++++++++++++++- .../Services/IAiFunctionProvider.cs | 10 +++ .../AiFunctionProviderTests.cs | 26 +++++++ 7 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 tests/OneWare.Chat.UnitTests/AiFunctionProviderTests.cs diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index 2e0f7f9b8..56d02cac8 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 24953d5cc..0ddb6f66f 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.Copilot/CopilotModule.cs b/src/OneWare.Copilot/CopilotModule.cs index e728b200d..4d3c8915b 100644 --- a/src/OneWare.Copilot/CopilotModule.cs +++ b/src/OneWare.Copilot/CopilotModule.cs @@ -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 9674653c7..412f7d9ea 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 7472cc233..f8da89258 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, @@ -2096,6 +2099,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 +2414,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 +2853,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.Essentials/Services/IAiFunctionProvider.cs b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs index 2db8b5209..2895b5b99 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/tests/OneWare.Chat.UnitTests/AiFunctionProviderTests.cs b/tests/OneWare.Chat.UnitTests/AiFunctionProviderTests.cs new file mode 100644 index 000000000..a47c04136 --- /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); + } +} From c1def8f984ee53ff8fc7d170f5f974f48728828e Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Wed, 14 Oct 2026 12:47:29 +0200 Subject: [PATCH 3/6] Only refresh packages when package source settings change Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/OneWareCloudLoginService.cs | 4 +++ .../Services/PackageService.cs | 20 ++++++++++- .../PackageServiceTests.cs | 36 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs index 6b7970935..89e93bc22 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.PackageManager/Services/PackageService.cs b/src/OneWare.PackageManager/Services/PackageService.cs index 88dac5cd2..89c1f94f4 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/tests/OneWare.PackageManager.UnitTests/PackageServiceTests.cs b/tests/OneWare.PackageManager.UnitTests/PackageServiceTests.cs index 7d369882a..e29099a74 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)); From ea836fbfb7123c8114b49e0208d1b223c37efea4 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Wed, 14 Oct 2026 13:01:02 +0200 Subject: [PATCH 4/6] Require Copilot CLI updates for BYOK and OneWare Agents chats too Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OneWare.Copilot/Services/CopilotChatService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index f8da89258..caa8ef6b5 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -1572,8 +1572,8 @@ 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) From 039f0fa0ec6461d27f68c01f0911e2e649a68e76 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Wed, 14 Oct 2026 13:01:58 +0200 Subject: [PATCH 5/6] Explain why OneWare Agents needs the Copilot CLI without a GitHub account Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OneWare.Copilot/CopilotModule.cs | 2 +- src/OneWare.Copilot/Services/CopilotChatService.cs | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/OneWare.Copilot/CopilotModule.cs b/src/OneWare.Copilot/CopilotModule.cs index 4d3c8915b..bc0729e30 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, diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index caa8ef6b5..d0d681edc 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -1393,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); @@ -1564,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)) @@ -1580,7 +1588,7 @@ public async Task InitializeAsync() { 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)) From 60579e18e923aa4c8691a3f02c172cba2434af1b Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Wed, 14 Oct 2026 13:12:00 +0200 Subject: [PATCH 6/6] fix opening legacy chats from history --- .../ViewModels/ChatSessionViewModel.cs | 8 +- src/OneWare.Chat/ViewModels/ChatViewModel.cs | 144 ++++++++++++++++-- 2 files changed, 140 insertions(+), 12 deletions(-) diff --git a/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs b/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs index 8d3ab525b..78f7cdbe8 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 c02573d93..0efa14eda 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; } }