From 404d6032625e25c9fb583201063a705f123bf156 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 29 Oct 2026 11:23:46 +0100 Subject: [PATCH 1/3] Copilot: connectors (MCP), skills, AGENTS.md and project context - IDE-wide MCP connectors under Settings > AI Chat > Connectors, stored in ~/.copilot/mcp-config.json, secrets in a shared ISecretStore, OAuth sign-in - Per-connector secret delivery via session config (no env leakage) - Load skills and AGENTS.md from user directories and open projects - Tool search enabled, core OneWare tools always loaded; surface context-blocked warnings in chat - Resolve login shell robustly for PATH (nvm/npx) - Remove getFocusedFile (replaced by attachments) and make focused-document tracking robust (floating windows, tab close, throwing handlers) - Cloud login refresh tokens moved to ISecretStore --- docs/PluginDevelopment.md | 87 +++ .../Services/AiBuiltInFunctions.cs | 18 - .../Services/OneWareCloudLoginService.cs | 85 +-- .../Connectors/CopilotConnectorService.cs | 653 ++++++++++++++++++ .../Connectors/CopilotRuntimeEnvironment.cs | 44 ++ .../Connectors/McpConnector.cs | 247 +++++++ .../Connectors/McpConnectorImport.cs | 172 +++++ src/OneWare.Copilot/CopilotModule.cs | 20 + .../Services/CopilotChatService.Workspace.cs | 442 ++++++++++++ .../Services/CopilotChatService.cs | 172 +++-- .../Services/CopilotSessionConfiguration.cs | 195 ++++++ .../Services/LoginShellEnvironment.cs | 123 ++++ .../Settings/CopilotConnectorsSetting.cs | 14 + .../CopilotConnectorEditorViewModel.cs | 511 ++++++++++++++ .../CopilotConnectorsSettingViewModel.cs | 364 ++++++++++ .../Views/CopilotConnectorEditorView.axaml | 146 ++++ .../Views/CopilotConnectorEditorView.axaml.cs | 11 + .../Views/CopilotConnectorsSettingView.axaml | 95 +++ .../CopilotConnectorsSettingView.axaml.cs | 26 + src/OneWare.Core/App.axaml.cs | 1 + src/OneWare.Core/OneWare.Core.csproj | 1 + src/OneWare.Core/Services/MainDockService.cs | 34 +- src/OneWare.Core/Services/SecretStore.cs | 58 ++ .../Services/ISecretStore.cs | 20 + .../ConnectorTests.cs | 269 ++++++++ .../CopilotSessionConfigurationTests.cs | 159 +++++ .../ProjectWorkspaceTests.cs | 122 ++++ 27 files changed, 3944 insertions(+), 145 deletions(-) create mode 100644 src/OneWare.Copilot/Connectors/CopilotConnectorService.cs create mode 100644 src/OneWare.Copilot/Connectors/CopilotRuntimeEnvironment.cs create mode 100644 src/OneWare.Copilot/Connectors/McpConnector.cs create mode 100644 src/OneWare.Copilot/Connectors/McpConnectorImport.cs create mode 100644 src/OneWare.Copilot/Services/CopilotChatService.Workspace.cs create mode 100644 src/OneWare.Copilot/Services/CopilotSessionConfiguration.cs create mode 100644 src/OneWare.Copilot/Services/LoginShellEnvironment.cs create mode 100644 src/OneWare.Copilot/Settings/CopilotConnectorsSetting.cs create mode 100644 src/OneWare.Copilot/ViewModels/CopilotConnectorEditorViewModel.cs create mode 100644 src/OneWare.Copilot/ViewModels/CopilotConnectorsSettingViewModel.cs create mode 100644 src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml create mode 100644 src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml.cs create mode 100644 src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml create mode 100644 src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml.cs create mode 100644 src/OneWare.Core/Services/SecretStore.cs create mode 100644 src/OneWare.Essentials/Services/ISecretStore.cs create mode 100644 tests/OneWare.Copilot.UnitTests/ConnectorTests.cs create mode 100644 tests/OneWare.Copilot.UnitTests/CopilotSessionConfigurationTests.cs create mode 100644 tests/OneWare.Copilot.UnitTests/ProjectWorkspaceTests.cs diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index 0361b47c..0725bcf3 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -505,6 +505,13 @@ created through `SoftwareProjectFactory`, which the creator dialog uses too. - `DownloadAndExtractArchiveAsync(url, location, progress, timeout, cancellationToken)`. - `DownloadImageAsync(url, timeout, cancellationToken)`, `DownloadTextAsync(url, timeout, cancellationToken)`. +#### `ISecretStore` (src/OneWare.Essentials/Services/ISecretStore.cs) + +- `Get(service, account)`, `Set(service, account, secret)`, `Remove(service, account)`: secrets in the OS + credential store (Keychain, Secret Service, Windows: DPAPI-encrypted files in the app data folder). +- Calls may block (unlock prompts, D-Bus), so call them off the UI thread. Pick a `service` name unique to + your plugin. The OneWare Cloud login and the Copilot connectors use it. + #### `IChatService` (src/OneWare.Essentials/Services/IChatService.cs) - `InitializeAsync()`: setup and authenticate. @@ -1007,6 +1014,86 @@ Names must be unique; registering an existing name replaces the previous agent o directories generated from `RegisterSkill` are cleaned up automatically when a skill is renamed or no longer registered. +### User skills, MCP servers and repository instructions + +The GitHub Copilot, BYOK and OneWare Agents chats reuse Copilot CLI customization discovery. +They read the user's Copilot configuration directory (`COPILOT_HOME`, or `~/.copilot` by default), +including user skills and the MCP servers in `~/.copilot/mcp-config.json`. Provider-specific chat +history remains separate. + +Add arbitrary skill roots in **Settings > AI Chat > Copilot CLI > Additional Skill Directories**. +These are merged with plugin skill directories, not substituted for them. Paths can be absolute, +start with `~/`, or be relative to the active project. Missing directories are logged and skipped. +Start or reopen a chat after changing customization settings. + +New and resumed sessions use the active project's root as their working directory, falling back to +the default projects directory if no project is active. The runtime discovers project skills and +repository instructions, including `AGENTS.md` and `.github/copilot-instructions.md`, from that context. +The assistant is also instructed to read scoped `AGENTS.md` files when working in subdirectories, +because OneWare's `readFile` tool replaces the CLI's built-in file viewer. + +#### Connectors (IDE-wide MCP servers) + +**Settings > AI Chat > Connectors** lists the MCP servers available in every chat, independent of the open +projects. Each connector can be added, edited, enabled or disabled, tested, signed in to and removed on its +own; *Import .mcp.json* adds the servers of an existing file (Copilot CLI `mcpServers`, VS Code `servers` or +Claude Code layout). + +- **Storage**: connectors are the servers of the Copilot CLI user configuration + (`~/.copilot/mcp-config.json`, edited through the runtime's configuration API), so the `copilot` CLI uses + the same list. Disabled connectors are kept in the CLI's `disabledMcpServers` setting. + `CopilotConnectorService` (`src/OneWare.Copilot/Connectors`) wraps this; it runs a short-lived CLI + process that does not need a Copilot login. +- **Secrets**: header and environment values marked *Secret* are stored with `ISecretStore` (service + `oneware-mcp`) under a generated variable such as `ONEWARE_MCP_GITHUB__AUTHORIZATION`, and the + configuration only contains `${ONEWARE_MCP_GITHUB__AUTHORIZATION}`. Other `${VAR}` references in commands, + arguments, environment values and headers are listed in the editor with their source (environment, stored + in OneWare, or not set), and a value can be stored for them as well. URLs must be literal. Connector names + (and secret keys within a connector) that only differ in case or punctuation are rejected, because they + would share variables. +- **Delivery of stored values**: they are never put into the runtime's environment, because every local MCP + server inherits that. Instead, a chat gets the connectors that use stored values as its own session MCP + servers (`SessionConfig.McpServers`) with only their own values filled in; these replace the configured + definitions of the same name. The runtime does not persist them. The standalone `copilot` CLI can't read + OneWare's stored values: export the variables there yourself. +- **Environment**: the runtime expands the remaining `${VAR}` references from its own process environment. + OneWare starts it with the login shell's environment (Linux/macOS, so tokens exported in `~/.profile`, + `~/.bashrc` or `~/.zshrc` and tools such as nvm's `npx` are found when OneWare is started from a desktop + launcher), overlaid with OneWare's own environment. `PATH` keeps the login shell's entries first. +- **Live updates**: adding, editing, enabling or removing a connector, or changing a stored value, reloads the + MCP servers of open chats and restarts the connectors that use stored values with their current values. +- **Tool search**: chats enable the runtime's tool search (`SessionConfig.ToolSearch`), so connectors with + many tools (Microsoft 365 has about 400) are looked up on demand instead of filling the model's context. + Above about 30 tools, MCP tools and plugin AI functions are deferred; the model finds them by name and + description, so give plugin functions precise, searchable descriptions. OneWare's core file, terminal and + IDE-context functions (`readFile`, `editFile`, `runTerminalCommand`, …) are always loaded + (`CopilotSessionConfiguration.AlwaysLoadedTools`). +- **Sign-in**: remote connectors are checked when the page opens; local ones only on *Test*, so their + command doesn't run unasked. *Sign In* runs the runtime's OAuth flow in the browser on the loopback + redirect `http://127.0.0.1:41347/`; the runtime keeps the tokens in the system keychain + (`McpOAuthTokenStorage = Persistent`), and open chats reconnect. A chat asks once per server to sign in + when a server reports `needs-auth`. Servers without dynamic client registration (e.g. Asana) need an OAuth + app registered with the provider using that redirect URL: enter its client ID and, for confidential apps, + its client secret (stored with `ISecretStore`, only sent when signing in). + +A project's own `.mcp.json` is only loaded by the Copilot runtime for the working directory, and only if +the Copilot CLI trusts that folder. OneWare does not start MCP servers from other open projects. + +#### Multiple open projects + +The runtime only knows one working directory, so OneWare adds the following from the other open projects +itself and keeps open chats in sync while projects are opened or closed: + +- **Skills** in `.github/skills`, `.agents/skills` and `.claude/skills` of every open project are added. +- **Instructions**: the root `AGENTS.md` and `.github/copilot-instructions.md` of the other projects + are sent with the next message, together with the list of open projects. They are sent again only when the + open projects or those files change. + +MCP tools use the normal permission flow, including per-chat approvals and the selected approval +mode. Ask/Plan and other read-only agents block MCP tools because OneWare cannot classify their side +effects. Host-capable built-in CLI tools remain unavailable, and customization discovery does not +enable repository command hooks. + ### Combining agents and skills `OneWareAiAgent.Skills` behaves differently from on-demand loading: the listed skills are diff --git a/src/OneWare.Chat/Services/AiBuiltInFunctions.cs b/src/OneWare.Chat/Services/AiBuiltInFunctions.cs index 22cdea00..5a0509e8 100644 --- a/src/OneWare.Chat/Services/AiBuiltInFunctions.cs +++ b/src/OneWare.Chat/Services/AiBuiltInFunctions.cs @@ -132,24 +132,6 @@ Do not assume or invent open files. DetailExtractor = _ => "open files" }); - functionProvider.RegisterFunction(new OneWareAiFunction - { - Name = "getFocusedFile", - IsReadOnly = true, - FriendlyName = "Get Focused File", - RunOnUiThread = true, - Description = """ - Returns the absolute full path of the currently focused editor file. - This is the ONLY way to know which file is active. - """, - Handler = () => new - { - currentFile = ResolvePath(projectExplorerService, dockService.CurrentDocument?.FullPath) - }, - DetailExtractor = _ => GetRelativePath(projectExplorerService, dockService.CurrentDocument?.FullPath) - ?? "focused file" - }); - functionProvider.RegisterFunction(new OneWareAiFunction { Name = "openFile", diff --git a/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs index 3603ad2d..01b8819b 100644 --- a/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs +++ b/src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs @@ -12,7 +12,6 @@ using System.Web; using Avalonia; using Avalonia.Threading; -using GitCredentialManager; using Microsoft.Extensions.Logging; using OneWare.CloudIntegration.Dto; using OneWare.Essentials.Extensions; @@ -31,7 +30,10 @@ public sealed class OneWareCloudLoginService : IOneWareCloudAccess private readonly IPaths _paths; private readonly SemaphoreSlim _semaphoreSlim = new(1, 1); private readonly ISettingsService _settingService; - private readonly string _tokenPath; + private readonly ISecretStore _secretStore; + + // Before the shared secret store, Windows refresh tokens were kept in Cloud/.bin; they are moved on use. + private readonly string _legacyWindowsTokenPath; private int? _port; private string? _codeVerifier; @@ -46,13 +48,14 @@ public sealed class OneWareCloudLoginService : IOneWareCloudAccess private CancellationTokenSource? _pendingLoginCts; public OneWareCloudLoginService(ILogger logger, ISettingsService settingService, IHttpService httpService, - IPaths paths) + IPaths paths, ISecretStore secretStore) { _logger = logger; _settingService = settingService; _httpService = httpService; _paths = paths; - _tokenPath = Path.Combine(paths.AppDataDirectory, "Cloud"); + _secretStore = secretStore; + _legacyWindowsTokenPath = Path.Combine(paths.AppDataDirectory, "Cloud"); settingService.GetSettingObservable(OneWareCloudIntegrationModule.OneWareCloudHostKey) .Skip(1) @@ -217,23 +220,9 @@ public void Logout(string userId) try { - string? refreshToken = null; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var tokenPath = Path.Combine(_tokenPath, $"{userId}.bin"); - if (File.Exists(tokenPath)) - { - refreshToken = TryReadWindowsRefreshToken(tokenPath); - File.Delete(tokenPath); - } - } - else - { - var store = CredentialManager.Create("oneware"); - refreshToken = store.Get(OneWareCloudIntegrationModule.CredentialStore, userId)?.Password; - store.Remove(OneWareCloudIntegrationModule.CredentialStore, userId); - } + MigrateLegacyWindowsRefreshToken(userId); + var refreshToken = _secretStore.Get(OneWareCloudIntegrationModule.CredentialStore, userId); + _secretStore.Remove(OneWareCloudIntegrationModule.CredentialStore, userId); _jwtBearerTokenCache.Remove(userId); @@ -246,19 +235,26 @@ public void Logout(string userId) } } - private string? TryReadWindowsRefreshToken(string tokenPath) + private void MigrateLegacyWindowsRefreshToken(string userId) { + if (!OperatingSystem.IsWindows()) return; + + var tokenPath = Path.Combine(_legacyWindowsTokenPath, $"{userId}.bin"); + if (!File.Exists(tokenPath)) return; + try { var plaintext = ProtectedData.Unprotect(File.ReadAllBytes(tokenPath), null, DataProtectionScope.CurrentUser); - return Encoding.UTF8.GetString(plaintext); + _secretStore.Set(OneWareCloudIntegrationModule.CredentialStore, userId, + Encoding.UTF8.GetString(plaintext)); } catch (Exception e) { _logger.Warning($"Could not read the stored refresh token: {e.Message}", null, false); - return null; } + + File.Delete(tokenPath); } /// Ends the offline session at the auth provider, so the signed-out token can no longer be used. @@ -343,20 +339,7 @@ private void StoreRefreshToken(string userId, string refreshToken) { try { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Directory.CreateDirectory(_tokenPath); - var tokenPath = Path.Combine(_tokenPath, $"{userId}.bin"); - - var plaintext = Encoding.UTF8.GetBytes(refreshToken); - var encrypted = ProtectedData.Protect(plaintext, null, DataProtectionScope.CurrentUser); - File.WriteAllBytes(tokenPath, encrypted); - } - else - { - var store = CredentialManager.Create("oneware"); - store.AddOrUpdate(OneWareCloudIntegrationModule.CredentialStore, userId, refreshToken); - } + _secretStore.Set(OneWareCloudIntegrationModule.CredentialStore, userId, refreshToken); } catch (Exception e) { @@ -799,29 +782,13 @@ async Task PersistAsync() } } - private async Task GetRefreshToken(string userId) + private Task GetRefreshToken(string userId) { - string? refreshToken = null; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return Task.Run(() => { - var tokenPath = Path.Combine(_tokenPath, $"{userId}.bin"); - if (File.Exists(tokenPath)) - { - var encrypted = await File.ReadAllBytesAsync(tokenPath); - var plaintext = ProtectedData.Unprotect(encrypted, null, DataProtectionScope.CurrentUser); - refreshToken = Encoding.UTF8.GetString(plaintext); - } - } - else - { - refreshToken = await Task.Run(() => - { - var store = CredentialManager.Create("oneware"); - return store.Get(OneWareCloudIntegrationModule.CredentialStore, userId)?.Password; - }); - } - - return refreshToken; + MigrateLegacyWindowsRefreshToken(userId); + return _secretStore.Get(OneWareCloudIntegrationModule.CredentialStore, userId); + }); } private static bool ShouldLogoutAfterTokenRefreshFailure(HttpStatusCode status) diff --git a/src/OneWare.Copilot/Connectors/CopilotConnectorService.cs b/src/OneWare.Copilot/Connectors/CopilotConnectorService.cs new file mode 100644 index 00000000..6ddb393f --- /dev/null +++ b/src/OneWare.Copilot/Connectors/CopilotConnectorService.cs @@ -0,0 +1,653 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Microsoft.Extensions.Logging; +using OneWare.Copilot.Services; +using OneWare.Essentials.Helpers; +using OneWare.Essentials.Services; + +namespace OneWare.Copilot.Connectors; + +public enum McpVariableSource +{ + Missing, + Environment, + OneWare +} + +public enum McpConnectorState +{ + Unknown, + Connected, + NeedsSignIn, + Failed +} + +public sealed record McpConnectorStatus(McpConnectorState State, string? Message = null); + +/// +/// Manages the IDE-wide MCP servers ("connectors"). Their definitions live in the Copilot CLI's user configuration +/// (~/.copilot/mcp-config.json), so the CLI and OneWare share them. Values stored in OneWare (secrets) are kept +/// in the OS credential store; the definitions reference them with ${VAR} and OneWare resolves them only for +/// the connector that references them, never in the runtime's environment, which every MCP server inherits. +/// OAuth sign-ins are run and kept by the runtime in its keychain storage. +/// +public sealed class CopilotConnectorService +{ + public const string SecretService = "oneware-mcp"; + + /// Redirect URL of the runtime's OAuth sign-in, needed when registering an OAuth app. + public const string OAuthRedirectUrl = "http://127.0.0.1:41347/"; + + private const string OAuthClientSecretPrefix = "oauth-client-secret:"; + private static readonly TimeSpan HelperIdleTimeout = TimeSpan.FromMinutes(1); + private static readonly TimeSpan SignInTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30); + + private readonly ISecretStore _secretStore; + private readonly IPaths _paths; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _helperLock = new(1, 1); + private readonly SemaphoreSlim _variablesLock = new(1, 1); + private Helper? _helper; + private CancellationTokenSource? _helperIdleCts; + + public CopilotConnectorService(ISecretStore secretStore, IPaths paths, ILogger logger) + { + _secretStore = secretStore; + _paths = paths; + _logger = logger; + } + + /// Connectors were added, edited, removed, enabled or disabled. + public event EventHandler? ConnectorsChanged; + + /// Values stored in OneWare changed. + public event EventHandler? ValuesChanged; + + /// The user signed in to the named connector. + public event EventHandler? SignedIn; + + public bool IsCliInstalled => File.Exists(CopilotModule.GetCliPath(_paths)); + + private string VariablesIndexFile => Path.Combine(_paths.AppDataDirectory, "Copilot", "mcp-variables.json"); + + private static bool IgnoreCase => OperatingSystem.IsWindows(); + + #region Environment + + /// + /// Environment for a Copilot CLI process, see . It never contains + /// values stored in OneWare. + /// + public static async Task> GetRuntimeEnvironmentAsync() => + CopilotRuntimeEnvironment.Merge(CopilotRuntimeEnvironment.GetProcessEnvironment(), + await LoginShellEnvironment.GetAsync(), IgnoreCase); + + public async Task GetVariableSourceAsync(string variable) + { + if ((await GetStoredVariablesAsync()).Contains(variable)) return McpVariableSource.OneWare; + + var environment = await GetRuntimeEnvironmentAsync(); + return environment.TryGetValue(variable, out var value) && !string.IsNullOrEmpty(value) + ? McpVariableSource.Environment + : McpVariableSource.Missing; + } + + /// Stores values in the credential store; or empty removes a value. + public async Task SetVariablesAsync(IReadOnlyDictionary values) + { + if (values.Count == 0) return; + + await _variablesLock.WaitAsync(); + try + { + await Task.Run(() => + { + var names = ReadVariablesIndex(); + foreach (var (name, value) in values) + { + if (string.IsNullOrEmpty(value)) + { + _secretStore.Remove(SecretService, name); + names.Remove(name); + } + else + { + _secretStore.Set(SecretService, name, value); + names.Add(name); + } + } + + WriteVariablesIndex(names); + }); + } + finally + { + _variablesLock.Release(); + } + + ValuesChanged?.Invoke(this, EventArgs.Empty); + } + + /// Moves stored values to other variable names, e.g. after renaming a connector. + public async Task RenameVariablesAsync(IReadOnlyDictionary renames) + { + var values = await Task.Run(() => + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var (from, to) in renames) + { + if (from == to || _secretStore.Get(SecretService, from) is not { Length: > 0 } value) continue; + result[to] = value; + result.TryAdd(from, null); + } + + return result; + }); + await SetVariablesAsync(values); + } + + public async Task> GetStoredVariablesAsync() + { + await _variablesLock.WaitAsync(); + try + { + return await Task.Run(ReadVariablesIndex); + } + finally + { + _variablesLock.Release(); + } + } + + public Task HasOAuthClientSecretAsync(string connector) => + Task.Run(() => !string.IsNullOrEmpty(_secretStore.Get(SecretService, OAuthClientSecretPrefix + connector))); + + /// Stores the secret of a confidential OAuth app; or empty removes it. + public Task SetOAuthClientSecretAsync(string connector, string? secret) => Task.Run(() => + { + if (string.IsNullOrEmpty(secret)) _secretStore.Remove(SecretService, OAuthClientSecretPrefix + connector); + else _secretStore.Set(SecretService, OAuthClientSecretPrefix + connector, secret); + }); + + /// + /// The connector with the values stored in OneWare filled in. Other ${VAR} references stay for the runtime + /// to expand from its environment. + /// + public async Task ResolveAsync(McpConnector connector) + { + var stored = await GetStoredVariablesAsync(); + var names = connector.GetVariables().Where(stored.Contains).ToList(); + if (names.Count == 0) return connector; + + var values = await Task.Run(() => names + .Select(x => (Name: x, Value: _secretStore.Get(SecretService, x))) + .Where(x => !string.IsNullOrEmpty(x.Value)) + .ToDictionary(x => x.Name, x => x.Value!, StringComparer.Ordinal)); + return connector.WithVariables(values); + } + + /// + /// Enabled connectors that use values stored in OneWare, resolved. Chats start these themselves instead of + /// letting the runtime start them from the configuration with the values missing. Reads the configuration file + /// directly, so no runtime has to be started for it. + /// + public async Task> GetResolvedConnectorsAsync() + { + var stored = await GetStoredVariablesAsync(); + if (stored.Count == 0) return []; + + var result = new List(); + foreach (var connector in await GetConfiguredConnectorsAsync()) + if (connector.Enabled && connector.GetVariables().Any(stored.Contains)) + result.Add(await ResolveAsync(connector)); + return result; + } + + // The credential store can't list entries, so the names (not the values) of stored variables are kept here. + private HashSet ReadVariablesIndex() + { + try + { + if (File.Exists(VariablesIndexFile) && + JsonSerializer.Deserialize(File.ReadAllText(VariablesIndexFile)) is { } names) + return new HashSet(names, StringComparer.Ordinal); + } + catch (Exception e) when (e is IOException or JsonException or UnauthorizedAccessException) + { + _logger.LogWarning(e, "Failed to read {File}.", VariablesIndexFile); + } + + return new HashSet(StringComparer.Ordinal); + } + + private void WriteVariablesIndex(IEnumerable names) + { + Directory.CreateDirectory(Path.GetDirectoryName(VariablesIndexFile)!); + File.WriteAllText(VariablesIndexFile, JsonSerializer.Serialize(names.Order(StringComparer.Ordinal))); + } + + #endregion + + #region Configuration + + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + var list = await UseHelperAsync(helper => helper.Client.Rpc.Mcp.Config.ListAsync(cancellationToken)); + var disabled = ReadDisabledServers(); + + return list.Servers + .Select(x => JsonNode.Parse(x.Value.GetRawText()) is JsonObject config + ? McpConnector.FromConfig(x.Key, config, !disabled.Contains(x.Key)) + : null) + .OfType() + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// Adds or updates a connector; renames an existing one. + public async Task SaveAsync(McpConnector connector, string? previousName = null) + { + await UseHelperAsync(async helper => + { + var config = helper.Client.Rpc.Mcp.Config; + var existing = (await config.ListAsync()).Servers.Keys.ToHashSet(StringComparer.Ordinal); + var json = JsonSerializer.SerializeToElement(connector.ToConfig()); + + if (previousName != null && previousName != connector.Name && existing.Contains(previousName)) + await config.RemoveAsync(previousName); + + if (existing.Contains(connector.Name)) await config.UpdateAsync(connector.Name, json); + else await config.AddAsync(connector.Name, json); + + if (connector.Enabled) await config.EnableAsync([connector.Name]); + else await config.DisableAsync([connector.Name]); + return true; + }); + + if (previousName != null && previousName != connector.Name) + await MoveOAuthClientSecretAsync(previousName, connector.Name); + + ConnectorsChanged?.Invoke(this, EventArgs.Empty); + } + + public async Task SetEnabledAsync(string name, bool enabled) + { + await UseHelperAsync(async helper => + { + if (enabled) await helper.Client.Rpc.Mcp.Config.EnableAsync([name]); + else await helper.Client.Rpc.Mcp.Config.DisableAsync([name]); + return true; + }); + ConnectorsChanged?.Invoke(this, EventArgs.Empty); + } + + /// Removes the connector and the values OneWare stored for it. + public async Task RemoveAsync(McpConnector connector) + { + await UseHelperAsync(async helper => + { + // Also drops the name from the CLI's disabled list, so a new connector with this name starts enabled. + await helper.Client.Rpc.Mcp.Config.EnableAsync([connector.Name]); + await helper.Client.Rpc.Mcp.Config.RemoveAsync(connector.Name); + return true; + }); + + await SetOAuthClientSecretAsync(connector.Name, null); + + var prefix = McpConnector.GetSecretVariablePrefix(connector.Name); + var stored = await GetStoredVariablesAsync(); + var owned = connector.GetVariables().Where(x => x.StartsWith(prefix, StringComparison.Ordinal) && + stored.Contains(x)).ToList(); + if (owned.Count > 0) await SetVariablesAsync(owned.ToDictionary(x => x, string? (_) => null)); + + ConnectorsChanged?.Invoke(this, EventArgs.Empty); + } + + private async Task MoveOAuthClientSecretAsync(string from, string to) + { + var secret = await Task.Run(() => _secretStore.Get(SecretService, OAuthClientSecretPrefix + from)); + if (string.IsNullOrEmpty(secret)) return; + await SetOAuthClientSecretAsync(to, secret); + await SetOAuthClientSecretAsync(from, null); + } + + /// The connectors as configured, read from the configuration file without starting a runtime. + public Task> GetConfiguredConnectorsAsync() => Task.Run(ReadConfigurationFile); + + private List ReadConfigurationFile() + { + var file = Path.Combine(CopilotSessionConfiguration.GetConfigDirectory(), "mcp-config.json"); + try + { + if (!File.Exists(file) || + JsonNode.Parse(File.ReadAllText(file), documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + })?["mcpServers"] is not JsonObject servers) + return []; + + var disabled = ReadDisabledServers(); + return servers.Where(x => x.Value is JsonObject) + .Select(x => McpConnector.FromConfig(x.Key, (JsonObject)x.Value!, !disabled.Contains(x.Key))) + .ToList(); + } + catch (Exception e) when (e is IOException or JsonException or UnauthorizedAccessException) + { + _logger.LogWarning(e, "Failed to read MCP servers from {File}.", file); + return []; + } + } + + // The configuration API doesn't report whether a server is disabled; the CLI keeps that in its settings. + private HashSet ReadDisabledServers() + { + var file = Path.Combine(CopilotSessionConfiguration.GetConfigDirectory(), "settings.json"); + try + { + if (File.Exists(file) && + JsonNode.Parse(File.ReadAllText(file), documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + })?["disabledMcpServers"] is JsonArray names) + return names.OfType().Select(x => x.TryGetValue(out var s) ? s : null) + .OfType().ToHashSet(StringComparer.Ordinal); + } + catch (Exception e) when (e is IOException or JsonException or UnauthorizedAccessException) + { + _logger.LogWarning(e, "Failed to read disabled MCP servers from {File}.", file); + } + + return new HashSet(StringComparer.Ordinal); + } + + #endregion + + #region Status and sign-in + + /// Starts the connector in a separate session to see whether it connects. Local ones run their command. + public Task CheckAsync(McpConnector connector, CancellationToken cancellationToken = default) => + UseHelperAsync(async helper => + { + var session = await helper.GetSessionAsync(); + try + { + return await StartAsync(session, connector, cancellationToken); + } + finally + { + try + { + await session.Rpc.Mcp.StopServerAsync(connector.Name, CancellationToken.None); + } + catch (Exception e) + { + _logger.LogDebug(e, "Failed to stop MCP server {Name} after checking it.", connector.Name); + } + } + }); + + /// + /// Runs the OAuth sign-in of a remote connector in the browser and waits until it connects. Tokens are kept by + /// the runtime, so every chat uses them afterwards. + /// + public async Task SignInAsync(McpConnector connector, + CancellationToken cancellationToken = default) + { + // A sign-in waits for the browser, so it gets its own runtime instead of blocking the shared helper; the + // runtime serves the OAuth redirect and must stay alive until then. + await using var helper = await Helper.CreateAsync(this); + var session = await helper.GetSessionAsync(); + + var status = await StartAsync(session, connector, cancellationToken); + if (status.State != McpConnectorState.NeedsSignIn) return status; + + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = session.On(e => + { + if (e.Data.ServerName != connector.Name) return; + if (e.Data.Status == McpServerStatus.Connected) + connected.TrySetResult(new McpConnectorStatus(McpConnectorState.Connected)); + else if (e.Data.Status == McpServerStatus.Failed) + connected.TrySetResult(new McpConnectorStatus(McpConnectorState.Failed, e.Data.Error)); + }); + + var clientSecret = await Task.Run(() => + _secretStore.Get(SecretService, OAuthClientSecretPrefix + connector.Name)); + McpOauthLoginResult login; + try + { + login = await session.Rpc.Mcp.Oauth.LoginAsync(new McpOauthLoginRequest + { + ServerName = connector.Name, + ClientName = "OneWare Studio", + CallbackSuccessMessage = "Signed in. You can close this tab and return to OneWare Studio.", + ClientId = string.IsNullOrWhiteSpace(connector.OAuthClientId) ? null : connector.OAuthClientId, + ClientSecret = string.IsNullOrEmpty(clientSecret) ? null : clientSecret, + PublicClient = connector.OAuthPublicClient ?? (string.IsNullOrEmpty(clientSecret) ? null : false) + }, cancellationToken); + } + catch (Exception e) when (e is not OperationCanceledException) + { + _logger.LogWarning(e, "Failed to sign in to MCP server {Name}.", connector.Name); + return new McpConnectorStatus(McpConnectorState.Failed, DescribeSignInError(e.Message)); + } + + // Without a URL, stored tokens were still valid and the server reconnects by itself. + if (!string.IsNullOrEmpty(login.AuthorizationUrl)) PlatformHelper.OpenHyperLink(login.AuthorizationUrl); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(SignInTimeout); + try + { + status = await connected.Task.WaitAsync(timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + status = await GetServerStatusAsync(session, connector.Name, CancellationToken.None); + if (status.State != McpConnectorState.Connected) + return new McpConnectorStatus(McpConnectorState.NeedsSignIn, "The sign-in was not completed."); + } + + if (status.State == McpConnectorState.Connected) SignedIn?.Invoke(this, connector.Name); + return status; + } + + public static string DescribeSignInError(string message) => + message.Contains("dynamic client registration", StringComparison.OrdinalIgnoreCase) + ? "The server doesn't let apps register themselves. Register an OAuth app with the provider, using " + + $"{OAuthRedirectUrl} as redirect URL, and enter its client ID (and secret) in the connector." + : message; + + internal static bool IsAuthenticationRequired(string message) => + message.Contains("requires authentication", StringComparison.OrdinalIgnoreCase) || + message.Contains("needs-auth", StringComparison.OrdinalIgnoreCase); + + private async Task StartAsync(CopilotSession session, McpConnector connector, + CancellationToken cancellationToken) + { + try + { + await session.Rpc.Mcp.StartServerAsync(new McpStartServerRequest + { + ServerName = connector.Name, + Config = JsonSerializer.SerializeToElement((await ResolveAsync(connector)).ToConfig()) + }, cancellationToken); + } + catch (Exception e) when (e is not OperationCanceledException) + { + if (IsAuthenticationRequired(e.Message)) return new McpConnectorStatus(McpConnectorState.NeedsSignIn); + return new McpConnectorStatus(McpConnectorState.Failed, StripRpcPrefix(e.Message)); + } + + return await GetServerStatusAsync(session, connector.Name, cancellationToken); + } + + private static async Task GetServerStatusAsync(CopilotSession session, string name, + CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(StartTimeout); + try + { + while (true) + { + var server = (await session.Rpc.Mcp.ListAsync(timeout.Token)).Servers.FirstOrDefault(x => x.Name == name); + if (server == null) return new McpConnectorStatus(McpConnectorState.Unknown); + if (server.Status == McpServerStatus.Connected) + return new McpConnectorStatus(McpConnectorState.Connected); + if (server.Status == McpServerStatus.NeedsAuth) + return new McpConnectorStatus(McpConnectorState.NeedsSignIn); + if (server.Status == McpServerStatus.Failed) + return new McpConnectorStatus(McpConnectorState.Failed, server.Error); + if (server.Status != McpServerStatus.Pending) + return new McpConnectorStatus(McpConnectorState.Unknown, server.Status.ToString()); + await Task.Delay(250, timeout.Token); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new McpConnectorStatus(McpConnectorState.Failed, "The server did not start in time."); + } + } + + private static string StripRpcPrefix(string message) + { + const string marker = "failed with message: "; + var index = message.IndexOf(marker, StringComparison.Ordinal); + return index < 0 ? message : message[(index + marker.Length)..]; + } + + #endregion + + #region Helper runtime + + /// + /// Runs on a shared, short-lived CLI process. It is shut down when idle, so it never + /// holds the CLI executable while it gets updated. + /// + private async Task UseHelperAsync(Func> action) + { + if (!IsCliInstalled) + throw new InvalidOperationException("The Copilot CLI is not installed. Open the AI chat to install it."); + + await _helperLock.WaitAsync(); + try + { + _helperIdleCts?.Cancel(); + _helper ??= await Helper.CreateAsync(this); + try + { + return await action(_helper); + } + catch (Exception e) when (e is IOException or ObjectDisposedException) + { + // The process may have exited; the next operation starts a new one. + await _helper.DisposeAsync(); + _helper = null; + throw; + } + } + finally + { + var idle = new CancellationTokenSource(); + _helperIdleCts = idle; + _helperLock.Release(); + _ = Task.Delay(HelperIdleTimeout, idle.Token).ContinueWith(_ => ResetHelperAsync(idle.Token), + TaskContinuationOptions.OnlyOnRanToCompletion); + } + } + + private async Task ResetHelperAsync(CancellationToken idleToken = default) + { + await _helperLock.WaitAsync(CancellationToken.None); + try + { + if (idleToken.IsCancellationRequested || _helper == null) return; + await _helper.DisposeAsync(); + _helper = null; + } + catch (Exception e) + { + _logger.LogDebug(e, "Failed to stop the connector helper runtime."); + } + finally + { + _helperLock.Release(); + } + } + + private sealed class Helper : IAsyncDisposable + { + private CopilotSession? _session; + + private Helper(CopilotClient client) + { + Client = client; + } + + public CopilotClient Client { get; } + + public static async Task CreateAsync(CopilotConnectorService service) + { + var connection = RuntimeConnection.ForStdio(CopilotModule.GetCliPath(service._paths), []); + connection.Environment = await GetRuntimeEnvironmentAsync(); + + // Default mode with the user's configuration directory, so the configuration API edits + // ~/.copilot/mcp-config.json; no Copilot account is needed for that. + var client = new CopilotClient(new CopilotClientOptions + { + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + Connection = connection, + UseLoggedInUser = false + }); + + try + { + await client.StartAsync(); + } + catch + { + await client.DisposeAsync(); + throw; + } + + return new Helper(client); + } + + /// A session without config discovery, used to start individual connectors. + public async Task GetSessionAsync() => _session ??= await Client.CreateSessionAsync( + new SessionConfig + { + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ClientName = "OneWare Studio", + EnableConfigDiscovery = false, + AvailableTools = new ToolSet().AddMcp("*"), + McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent, + OnPermissionRequest = (_, _) => + Task.FromResult(PermissionDecision.Reject("Connector checks don't run tools.")) + }); + + public async ValueTask DisposeAsync() + { + try + { + if (_session != null) await _session.DisposeAsync(); + } + catch + { + // The process is stopped below anyway. + } + + await Client.DisposeAsync(); + } + } + + #endregion +} diff --git a/src/OneWare.Copilot/Connectors/CopilotRuntimeEnvironment.cs b/src/OneWare.Copilot/Connectors/CopilotRuntimeEnvironment.cs new file mode 100644 index 00000000..85a28f5f --- /dev/null +++ b/src/OneWare.Copilot/Connectors/CopilotRuntimeEnvironment.cs @@ -0,0 +1,44 @@ +using System.Collections; +using System.IO; + +namespace OneWare.Copilot.Connectors; + +/// Environment of the Copilot CLI process, which MCP servers inherit and ${VAR} references use. +internal static class CopilotRuntimeEnvironment +{ + /// + /// Merges the login shell's environment (variables and tools such as nvm's npx that apps started from a + /// desktop launcher don't see) with OneWare's own, which wins. PATH keeps the login shell's entries + /// first and appends OneWare's missing ones. Every MCP server inherits this, so it must not contain secrets + /// stored in OneWare. + /// + public static Dictionary Merge(IReadOnlyDictionary process, + IReadOnlyDictionary loginShell, bool ignoreCase) + { + var comparer = ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + var result = new Dictionary(loginShell, comparer); + foreach (var (key, value) in process) result[key] = value; + + if (loginShell.TryGetValue("PATH", out var loginPath) && !string.IsNullOrEmpty(loginPath)) + { + var entries = loginPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); + if (process.TryGetValue("PATH", out var processPath)) + entries.AddRange(processPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Where(x => !entries.Contains(x, comparer))); + result["PATH"] = string.Join(Path.PathSeparator, entries.Distinct(comparer)); + } + + return result; + } + + public static Dictionary GetProcessEnvironment() + { + var result = new Dictionary(OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + if (entry is { Key: string key, Value: string value }) + result[key] = value; + return result; + } +} diff --git a/src/OneWare.Copilot/Connectors/McpConnector.cs b/src/OneWare.Copilot/Connectors/McpConnector.cs new file mode 100644 index 00000000..1ac045db --- /dev/null +++ b/src/OneWare.Copilot/Connectors/McpConnector.cs @@ -0,0 +1,247 @@ +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using GitHub.Copilot; + +namespace OneWare.Copilot.Connectors; + +public enum McpConnectorKind +{ + Local, + Remote +} + +public sealed record McpConnectorValue(string Key, string Value); + +/// +/// An MCP server ("connector") in the Copilot CLI's user configuration (mcp-config.json). Values may reference +/// ${VAR} environment variables, which the CLI resolves from its process environment. Variables stored in +/// OneWare (secrets) are filled in by OneWare, only for the connector that references them. +/// +public sealed partial class McpConnector +{ + // Fields edited by OneWare; everything else from the CLI configuration (tools, timeout, …) is kept as is. + private static readonly string[] EditedFields = + ["type", "command", "args", "cwd", "env", "url", "headers", "oauthClientId", "oauthPublicClient"]; + + public string Name { get; set; } = string.Empty; + + public McpConnectorKind Kind { get; set; } + + public bool Enabled { get; set; } = true; + + public string Command { get; set; } = string.Empty; + + public List Args { get; set; } = []; + + public string? WorkingDirectory { get; set; } + + public List Env { get; set; } = []; + + public string Url { get; set; } = string.Empty; + + /// Uses the legacy SSE transport instead of streamable HTTP. + public bool UseSse { get; set; } + + public List Headers { get; set; } = []; + + public string? OAuthClientId { get; set; } + + public bool? OAuthPublicClient { get; set; } + + public JsonObject OtherFields { get; set; } = new(); + + [GeneratedRegex(@"\$\{(?[A-Za-z_][A-Za-z0-9_]*)\}")] + private static partial Regex VariableRegex(); + + [GeneratedRegex("^[A-Za-z0-9_.-]+$")] + private static partial Regex NameRegex(); + + public static bool IsValidName(string name) => NameRegex().IsMatch(name) && GetVariableKey(name).Length > 0; + + public static McpConnector FromConfig(string name, JsonObject config, bool enabled) + { + var url = GetString(config, "url"); + var command = GetString(config, "command"); + var type = GetString(config, "type")?.Trim().ToLowerInvariant(); + var connector = new McpConnector + { + Name = name, + Enabled = enabled, + Kind = type is "http" or "sse" || (type == null && command == null && url != null) + ? McpConnectorKind.Remote + : McpConnectorKind.Local, + UseSse = type == "sse", + Command = command ?? string.Empty, + Args = GetStrings(config, "args"), + WorkingDirectory = GetString(config, "cwd"), + Env = GetValues(config, "env"), + Url = url ?? string.Empty, + Headers = GetValues(config, "headers"), + OAuthClientId = GetString(config, "oauthClientId"), + OAuthPublicClient = config["oauthPublicClient"] is JsonValue publicClient && + publicClient.TryGetValue(out var isPublic) + ? isPublic + : null + }; + + foreach (var (key, value) in config) + if (!EditedFields.Contains(key)) + connector.OtherFields[key] = value?.DeepClone(); + + return connector; + } + + /// The configuration written to the CLI. Values keep their ${VAR} references. + public JsonObject ToConfig() + { + var config = new JsonObject(); + + if (Kind == McpConnectorKind.Local) + { + config["type"] = "stdio"; + config["command"] = Command.Trim(); + if (Args.Count > 0) config["args"] = new JsonArray(Args.Select(x => (JsonNode?)x).ToArray()); + if (!string.IsNullOrWhiteSpace(WorkingDirectory)) config["cwd"] = WorkingDirectory.Trim(); + if (Env.Count > 0) config["env"] = ToObject(Env); + } + else + { + config["type"] = UseSse ? "sse" : "http"; + config["url"] = Url.Trim(); + if (Headers.Count > 0) config["headers"] = ToObject(Headers); + if (!string.IsNullOrWhiteSpace(OAuthClientId)) config["oauthClientId"] = OAuthClientId.Trim(); + if (OAuthPublicClient is { } isPublic) config["oauthPublicClient"] = isPublic; + } + + foreach (var (key, value) in OtherFields) config[key] = value?.DeepClone(); + config["tools"] ??= new JsonArray("*"); + + return config; + } + + /// + /// The connector as a session's own MCP server, which replaces the one in the configuration. null for SSE + /// servers, which the SDK's session configuration can't express. + /// + public McpServerConfig? ToServerConfig() + { + if (Kind == McpConnectorKind.Remote && UseSse) return null; + + var config = ToConfig(); + McpServerConfig result = Kind == McpConnectorKind.Local + ? new McpStdioServerConfig + { + Command = Command.Trim(), + Args = Args.ToList(), + Env = Env.Where(x => !string.IsNullOrWhiteSpace(x.Key)) + .ToDictionary(x => x.Key.Trim(), x => x.Value), + WorkingDirectory = string.IsNullOrWhiteSpace(WorkingDirectory) ? null : WorkingDirectory.Trim() + } + : new McpHttpServerConfig + { + Url = Url.Trim(), + Headers = Headers.Where(x => !string.IsNullOrWhiteSpace(x.Key)) + .ToDictionary(x => x.Key.Trim(), x => x.Value), + OauthClientId = string.IsNullOrWhiteSpace(OAuthClientId) ? null : OAuthClientId.Trim(), + OauthPublicClient = OAuthPublicClient + }; + + result.Tools = GetStrings(config, "tools"); + if (config["timeout"] is JsonValue timeout && timeout.TryGetValue(out var ms)) result.Timeout = ms; + return result; + } + + /// Environment variables the connector references with ${VAR}, sorted. + public IReadOnlyList GetVariables() + { + IEnumerable values = Kind == McpConnectorKind.Local + ? [Command, WorkingDirectory ?? string.Empty, ..Args, ..Env.Select(x => x.Value)] + : [..Headers.Select(x => x.Value)]; + + return values.SelectMany(x => VariableRegex().Matches(x).Select(m => m.Groups["name"].Value)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToList(); + } + + /// + /// Variable a secret value of this connector is stored under, e.g. ONEWARE_MCP_GITHUB__AUTHORIZATION. + /// The parts never contain __, so different connectors can't produce the same name as long as their + /// differs. + /// + public static string GetSecretVariableName(string connector, string key) => + $"{GetSecretVariablePrefix(connector)}{GetVariableKey(key)}"; + + /// Prefix of all variables generates for a connector. + public static string GetSecretVariablePrefix(string connector) => $"ONEWARE_MCP_{GetVariableKey(connector)}__"; + + /// + /// The form of a connector name or value key used in generated variable names. Names or keys with the same form + /// (e.g. my-server and My_Server) would share variables, so they must not be used side by side. + /// + public static string GetVariableKey(string value) => + string.Join('_', new string(value.ToUpperInvariant().Select(c => char.IsAsciiLetterOrDigit(c) ? c : '_') + .ToArray()).Split('_', StringSplitOptions.RemoveEmptyEntries)); + + /// + /// A copy with the given variables filled in. Unknown ${VAR} references stay as they are. + /// + public McpConnector WithVariables(IReadOnlyDictionary values) + { + var result = Clone(); + string Fill(string value) => VariableRegex().Replace(value, + m => values.TryGetValue(m.Groups["name"].Value, out var v) ? v : m.Value); + + result.Command = Fill(result.Command); + if (result.WorkingDirectory != null) result.WorkingDirectory = Fill(result.WorkingDirectory); + result.Args = result.Args.Select(Fill).ToList(); + result.Env = result.Env.Select(x => x with { Value = Fill(x.Value) }).ToList(); + result.Headers = result.Headers.Select(x => x with { Value = Fill(x.Value) }).ToList(); + return result; + } + + /// Whether is exactly one ${VAR} reference. + public static bool IsVariableReference(string value, out string variable) + { + var match = VariableRegex().Match(value); + variable = match.Success && match.Length == value.Length ? match.Groups["name"].Value : string.Empty; + return variable.Length > 0; + } + + public static string ToVariableReference(string variable) => "${" + variable + "}"; + + public string Describe() => Kind == McpConnectorKind.Remote + ? Url + : string.Join(' ', new[] { Command }.Concat(Args).Where(x => !string.IsNullOrEmpty(x))); + + public McpConnector Clone() + { + var clone = FromConfig(Name, ToConfig(), Enabled); + // ToConfig adds the default tool filter; a clone must not differ from the original. + if (!OtherFields.ContainsKey("tools")) clone.OtherFields.Remove("tools"); + return clone; + } + + private static JsonObject ToObject(IEnumerable values) + { + var result = new JsonObject(); + foreach (var value in values.Where(x => !string.IsNullOrWhiteSpace(x.Key))) + result[value.Key.Trim()] = value.Value; + return result; + } + + private static string? GetString(JsonObject obj, string key) => + obj[key] is JsonValue value && value.TryGetValue(out var text) ? text : null; + + private static List GetStrings(JsonObject obj, string key) => + obj[key] is JsonArray array + ? array.OfType().Select(x => x.TryGetValue(out var s) ? s : null).OfType() + .ToList() + : []; + + private static List GetValues(JsonObject obj, string key) => + obj[key] is JsonObject map + ? map.Where(x => x.Value is JsonValue v && v.TryGetValue(out _)) + .Select(x => new McpConnectorValue(x.Key, x.Value!.GetValue())).ToList() + : []; +} diff --git a/src/OneWare.Copilot/Connectors/McpConnectorImport.cs b/src/OneWare.Copilot/Connectors/McpConnectorImport.cs new file mode 100644 index 00000000..2c4774b5 --- /dev/null +++ b/src/OneWare.Copilot/Connectors/McpConnectorImport.cs @@ -0,0 +1,172 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace OneWare.Copilot.Connectors; + +/// Secret of a pre-registered OAuth client (oauthClientSecret), if declared. +public sealed record ImportedMcpConnector(McpConnector Connector, string? OAuthClientSecret); + +/// +/// Reads connectors from MCP configuration files in the formats of the Copilot CLI (mcpServers), VS Code +/// (servers) and Claude Code (top-level map). +/// +public static partial class McpConnectorImport +{ + [GeneratedRegex(@"\$\{(?[A-Za-z_][A-Za-z0-9_:]*)(?::-(?[^}]*))?\}")] + private static partial Regex VariableRegex(); + + public static IReadOnlyList Read(string file, Action warn) + { + JsonNode? document; + try + { + document = JsonNode.Parse(File.ReadAllText(file), documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }); + } + catch (Exception e) when (e is JsonException or IOException or UnauthorizedAccessException) + { + warn($"Failed to read {file}: {e.Message}"); + return []; + } + + if (document is not JsonObject root) + { + warn($"{file} must contain a JSON object."); + return []; + } + + var map = root["mcpServers"] as JsonObject ?? root["servers"] as JsonObject ?? root; + var directory = Path.GetDirectoryName(Path.GetFullPath(file))!; + var result = new List(); + + foreach (var (name, node) in map) + { + if (node is not JsonObject declaration) continue; + if (!McpConnector.IsValidName(name)) + { + warn($"'{name}' is not a valid connector name (letters, digits, '.', '-' and '_')."); + continue; + } + + if (Convert(name, declaration, directory, message => warn($"{name}: {message}")) is { } imported) + result.Add(imported); + } + + return result; + } + + private static ImportedMcpConnector? Convert(string name, JsonObject declaration, string directory, + Action warn) + { + string Normalize(string value) => NormalizeVariables(value, directory, warn); + + var command = GetString(declaration, "command"); + var url = GetString(declaration, "url"); + var type = GetString(declaration, "type")?.Trim().ToLowerInvariant() switch + { + null or "" => command != null ? "stdio" : url != null ? "http" : null, + "stdio" or "local" => "stdio", + "http" or "streamable-http" or "streamablehttp" => "http", + "sse" => "sse", + var other => Unsupported(other) + }; + + string? Unsupported(string value) + { + warn($"the type '{value}' is not supported."); + return null; + } + + var config = new JsonObject(); + string? clientSecret = null; + + if (type == "stdio" && !string.IsNullOrWhiteSpace(command)) + { + config["command"] = Normalize(command); + if (declaration["args"] is JsonArray args) + config["args"] = new JsonArray(args.OfType() + .Select(x => x.TryGetValue(out var s) ? (JsonNode?)Normalize(s) : null) + .OfType().ToArray()); + if (declaration["env"] is JsonObject env) config["env"] = MapValues(env, Normalize); + // Project files expect to run in their folder, a connector runs wherever the chat is. + var cwd = GetString(declaration, "cwd"); + config["cwd"] = string.IsNullOrWhiteSpace(cwd) ? directory : Path.GetFullPath(Normalize(cwd), directory); + } + else if (type is "http" or "sse" && !string.IsNullOrWhiteSpace(url)) + { + config["type"] = type; + // The CLI only accepts a literal url. + config["url"] = ExpandVariables(url, directory, warn); + if (declaration["headers"] is JsonObject headers) config["headers"] = MapValues(headers, Normalize); + if (GetString(declaration, "oauthClientId") is { } clientId) + config["oauthClientId"] = ExpandVariables(clientId, directory, warn); + if (declaration["oauthPublicClient"]?.DeepClone() is { } publicClient) + config["oauthPublicClient"] = publicClient; + if (GetString(declaration, "oauthClientSecret") is { } secret) + clientSecret = ExpandVariables(secret, directory, warn); + } + else + { + if (type != null || command != null || url != null) + warn(type == "stdio" ? "a command is required." : "a url is required."); + else warn("a command or a url is required."); + return null; + } + + foreach (var key in new[] { "tools", "timeout" }) + if (declaration[key]?.DeepClone() is { } value) + config[key] = value; + + var enabled = !(declaration["disabled"] is JsonValue disabled && disabled.TryGetValue(out var off) && + off); + return new ImportedMcpConnector(McpConnector.FromConfig(name, config, enabled), clientSecret); + } + + /// + /// Rewrites references to the CLI's ${VAR} syntax: ${env:VAR} becomes ${VAR} and + /// ${workspaceFolder} the folder of the imported file. + /// + internal static string NormalizeVariables(string value, string workspaceFolder, Action warn) => + VariableRegex().Replace(value, match => + { + var name = match.Groups["name"].Value; + if (name is "workspaceFolder" or "workspaceRoot") return workspaceFolder; + if (name.StartsWith("env:", StringComparison.Ordinal)) name = name[4..]; + if (name.Contains(':')) return match.Value; + if (match.Groups["default"].Success) + warn($"the default value of ${{{name}}} is not supported and was dropped."); + return McpConnector.ToVariableReference(name); + }); + + private static string ExpandVariables(string value, string workspaceFolder, Action warn) => + VariableRegex().Replace(value, match => + { + var name = match.Groups["name"].Value; + if (name is "workspaceFolder" or "workspaceRoot") return workspaceFolder; + if (name.StartsWith("env:", StringComparison.Ordinal)) name = name[4..]; + + var resolved = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrEmpty(resolved)) return resolved; + if (match.Groups["default"].Success) return match.Groups["default"].Value; + + warn($"${{{name}}} is not defined."); + return string.Empty; + }); + + private static JsonObject MapValues(JsonObject values, Func map) + { + var result = new JsonObject(); + foreach (var (key, value) in values) + if (value is JsonValue v && v.TryGetValue(out var text)) + result[key] = map(text); + return result; + } + + private static string? GetString(JsonObject obj, string key) => + obj[key] is JsonValue value && value.TryGetValue(out var text) ? text : null; +} diff --git a/src/OneWare.Copilot/CopilotModule.cs b/src/OneWare.Copilot/CopilotModule.cs index d234278d..41647387 100644 --- a/src/OneWare.Copilot/CopilotModule.cs +++ b/src/OneWare.Copilot/CopilotModule.cs @@ -1,7 +1,9 @@ using System.Reactive.Linq; using Microsoft.Extensions.DependencyInjection; using OneWare.CloudIntegration; +using OneWare.Copilot.Connectors; using OneWare.Copilot.Services; +using OneWare.Copilot.Settings; using OneWare.Copilot.Views; using OneWare.Essentials.Helpers; using OneWare.Essentials.Models; @@ -17,6 +19,8 @@ public class CopilotModule : OneWareModuleBase public const string CopilotApprovalModeSettingKey = "AI_Chat_Copilot_ApprovalMode"; public const string CopilotContextTierSettingKey = "AI_Chat_Copilot_ContextTier"; public const string CopilotAutoTierSettingKey = "AI_Chat_Copilot_AutoTier"; + public const string CopilotSkillDirectoriesSettingKey = "AI_Chat_Copilot_SkillDirectories"; + public const string CopilotConnectorsSettingKey = "AI_Chat_Copilot_Connectors"; public const string CopilotProviderSettingKey = "AI_Chat_Copilot_Provider"; public const string CopilotByokEndpointSettingKey = "AI_Chat_Copilot_BYOK_Endpoint"; public const string CopilotByokApiKeyEnvironmentVariableSettingKey = @@ -144,6 +148,7 @@ public static string GetCliPath(IPaths paths) public override void RegisterServices(IServiceCollection services) { + services.AddSingleton(); services.AddTransient(); services.AddTransient(); } @@ -159,6 +164,21 @@ public override void Initialize(IServiceProvider serviceProvider) var settingsService = serviceProvider.Resolve(); + settingsService.RegisterSetting("AI Chat", "Copilot CLI", CopilotSkillDirectoriesSettingKey, + new ListBoxSetting("Additional Skill Directories") + { + HoverDescription = + "Additional folders containing SKILL.md bundles, alongside Copilot CLI user/project skills " + + "and plugin skills. Supports absolute paths, ~/ paths, and paths relative to the active " + + "project. Applied when starting or reopening a chat." + }); + + settingsService.RegisterCustom("AI Chat", "Connectors", CopilotConnectorsSettingKey, + new CopilotConnectorsSetting(serviceProvider.Resolve())); + + // Reading the login shell's environment takes a moment; the first chat needs it. + _ = LoginShellEnvironment.GetAsync(); + settingsService.RegisterSetting("AI Chat", "Copilot CLI", CopilotProviderSettingKey, new ComboBoxSetting("Model Provider", ProviderGitHubCopilot, [ProviderGitHubCopilot, ProviderOpenAiCompatible, ProviderAnthropic]) diff --git a/src/OneWare.Copilot/Services/CopilotChatService.Workspace.cs b/src/OneWare.Copilot/Services/CopilotChatService.Workspace.cs new file mode 100644 index 00000000..9f190917 --- /dev/null +++ b/src/OneWare.Copilot/Services/CopilotChatService.Workspace.cs @@ -0,0 +1,442 @@ +using System.Collections.Specialized; +using System.IO; +using System.Text.Json; +using Avalonia.Controls; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.Input; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Microsoft.Extensions.Logging; +using OneWare.Copilot.Connectors; +using OneWare.Essentials.Helpers; +using OneWare.Essentials.Models; +using OneWare.Essentials.Services; + +namespace OneWare.Copilot.Services; + +/// +/// Keeps a chat in sync with the IDE: the skills and instruction files of every open project (the runtime only +/// discovers its working directory) and the connectors (IDE-wide MCP servers) edited in the settings. +/// +public abstract partial class CopilotChatServiceBase +{ + private string? _sessionWorkingDirectory; + private string? _announcedWorkspaceKey; + private CancellationTokenSource? _workspaceCts; + private CancellationTokenSource? _projectsRefreshCts; + private readonly SemaphoreSlim _workspaceGate = new(1, 1); + private INotifyCollectionChanged? _observedProjects; + private CopilotConnectorService? _observedConnectors; + + // MCP problems already shown in this session (sign-in offers, failures), so they are not repeated. + private readonly HashSet _reportedMcpProblems = new(StringComparer.Ordinal); + + // Servers the user started signing in to from the chat, so their reconnection is confirmed in the chat. + private readonly HashSet _mcpSignInsInProgress = new(StringComparer.Ordinal); + + // Connectors this session runs with values stored in OneWare filled in, by name. The runtime only knows their + // definitions with ${VAR} references, so the session gets them as its own servers. + private readonly Dictionary _sessionConnectors = new(StringComparer.Ordinal); + private readonly SemaphoreSlim _connectorsGate = new(1, 1); + private volatile bool _applyingConnectors; + + private static ILogger Logger => ContainerLocator.Container.Resolve(); + + private static async Task> GetOpenProjectRootsAsync() + { + var explorer = ContainerLocator.Container.Resolve(); + + List Snapshot() + { + var roots = explorer.Projects.Select(x => x.RootFolderPath) + .Where(x => !string.IsNullOrWhiteSpace(x) && Directory.Exists(x)) + .Select(Path.GetFullPath) + .Distinct(CopilotSessionConfiguration.PathComparer) + .ToList(); + + // The active project goes first, matching the session's working directory. + if (explorer.ActiveProject?.RootFolderPath is { } active && + roots.FindIndex(x => CopilotSessionConfiguration.PathComparer.Equals(x, Path.GetFullPath(active))) + is > 0 and var index) + { + var root = roots[index]; + roots.RemoveAt(index); + roots.Insert(0, root); + } + + return roots; + } + + return Dispatcher.UIThread.CheckAccess() ? Snapshot() : await Dispatcher.UIThread.InvokeAsync(Snapshot); + } + + private void SubscribeToProjectChanges() + { + if (_observedProjects != null) return; + + _observedProjects = ContainerLocator.Container.Resolve().Projects; + _observedProjects.CollectionChanged += OnProjectsChanged; + + _observedConnectors = ContainerLocator.Container.Resolve(); + _observedConnectors.ConnectorsChanged += OnConnectorsChanged; + _observedConnectors.SignedIn += OnConnectorSignedIn; + _observedConnectors.ValuesChanged += OnConnectorsChanged; + } + + private void UnsubscribeFromProjectChanges() + { + if (_observedProjects != null) _observedProjects.CollectionChanged -= OnProjectsChanged; + _observedProjects = null; + if (_observedConnectors != null) + { + _observedConnectors.ConnectorsChanged -= OnConnectorsChanged; + _observedConnectors.SignedIn -= OnConnectorSignedIn; + _observedConnectors.ValuesChanged -= OnConnectorsChanged; + } + + _observedConnectors = null; + Interlocked.Exchange(ref _projectsRefreshCts, null)?.Cancel(); + } + + /// + /// The runtime reads the connectors from its configuration; reloading applies edits to this chat. Connectors + /// with values stored in OneWare are then restarted with those values. + /// + private void OnConnectorsChanged(object? sender, EventArgs e) + { + if (_session is not { } session) return; + lock (_reportedMcpProblems) _reportedMcpProblems.Clear(); + _ = ApplyConnectorsAsync(session, reload: true); + } + + private void OnConnectorSignedIn(object? sender, string serverName) + { + if (_session is not { } session) return; + lock (_reportedMcpProblems) _reportedMcpProblems.Remove($"auth:{serverName}"); + _ = Task.Run(async () => + { + try + { + // The tokens were stored by another runtime process; this one picks them up on reconnecting. + await session.Rpc.Mcp.Oauth.AuthenticationStateChangedAsync(serverName); + McpConnector? resolved; + lock (_sessionConnectors) _sessionConnectors.TryGetValue(serverName, out resolved); + await session.Rpc.Mcp.RestartServerAsync(serverName, + resolved == null ? null : JsonSerializer.SerializeToElement(resolved.ToConfig())); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to reconnect MCP server {Name} after signing in.", serverName); + } + }); + } + + /// + /// MCP servers for a new session: the connectors that use values stored in OneWare, with the values filled in. + /// They replace the configuration's definitions, so the values reach only the server that references them + /// instead of the runtime's environment, which every server inherits. + /// + private async Task?> BuildConnectorServersAsync() + { + IReadOnlyList resolved; + try + { + resolved = await ContainerLocator.Container.Resolve() + .GetResolvedConnectorsAsync(); + } + catch (Exception e) + { + Logger.LogWarning(e, "Failed to read the connectors for the Copilot session."); + resolved = []; + } + + lock (_sessionConnectors) + { + _sessionConnectors.Clear(); + foreach (var connector in resolved) _sessionConnectors[connector.Name] = connector; + } + + var servers = new Dictionary(StringComparer.Ordinal); + foreach (var connector in resolved) + if (connector.ToServerConfig() is { } config) + servers[connector.Name] = config; + return servers.Count > 0 ? servers : null; + } + + /// + /// Starts the connectors with stored values on . After ing + /// the configuration all of them are restarted with their current values, and connectors that no longer use + /// stored values go back to their configured definition. + /// + private async Task ApplyConnectorsAsync(CopilotSession session, bool reload) + { + var service = ContainerLocator.Container.Resolve(); + try + { + await _connectorsGate.WaitAsync(); + _applyingConnectors = true; + try + { + if (!ReferenceEquals(_session, session)) return; + if (reload) await session.Rpc.Mcp.ReloadAsync(); + + var resolved = await service.GetResolvedConnectorsAsync(); + List previous; + lock (_sessionConnectors) + { + previous = _sessionConnectors.Keys.ToList(); + _sessionConnectors.Clear(); + foreach (var connector in resolved) _sessionConnectors[connector.Name] = connector; + } + + // A new session already got the others through its configuration. + foreach (var connector in resolved.Where(x => reload || x.ToServerConfig() == null)) + await StartConnectorAsync(session, connector); + + if (!reload) return; + var configured = await service.GetConfiguredConnectorsAsync(); + foreach (var name in previous.Where(x => resolved.All(r => r.Name != x))) + { + // The session still runs it with the values it had before. + if (configured.FirstOrDefault(x => x.Name == name) is { Enabled: true } connector) + await StartConnectorAsync(session, connector); + else + await session.Rpc.Mcp.StopServerAsync(name); + } + } + finally + { + _applyingConnectors = false; + _connectorsGate.Release(); + } + } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException) + { + // The session was closed meanwhile. + } + catch (Exception e) + { + Logger.LogWarning(e, "Failed to apply the connectors to the Copilot session."); + } + } + + private static async Task StartConnectorAsync(CopilotSession session, McpConnector connector) + { + var config = JsonSerializer.SerializeToElement(connector.ToConfig()); + try + { + await session.Rpc.Mcp.RestartServerAsync(connector.Name, config); + } + catch (Exception e) + { + Logger.LogDebug(e, "Restarting MCP server {Name} failed, starting it.", connector.Name); + await session.Rpc.Mcp.StartServerAsync(connector.Name, config); + } + } + + private void OnProjectsChanged(object? sender, NotifyCollectionChangedEventArgs e) => + ScheduleWorkspaceSync(updateSkills: true); + + private void ScheduleWorkspaceSync(bool updateSkills) + { + var session = _session; + if (session == null) return; + + // Opening a folder with several projects or editing a list raises a burst of changes; they are applied + // together. + var cts = new CancellationTokenSource(); + Interlocked.Exchange(ref _projectsRefreshCts, cts)?.Cancel(); + _ = Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token).ContinueWith(t => + { + if (!t.IsCanceled) _ = StartWorkspaceSync(session, updateSkills); + }, TaskScheduler.Default); + } + + private Task StartWorkspaceSync(CopilotSession session, bool updateSkills) + { + var token = (_workspaceCts ??= new CancellationTokenSource()).Token; + return SyncWorkspaceAsync(session, updateSkills, token); + } + + private void ResetWorkspaceState() + { + Interlocked.Exchange(ref _workspaceCts, null)?.Cancel(); + Interlocked.Exchange(ref _projectsRefreshCts, null)?.Cancel(); + lock (_reportedMcpProblems) + { + _reportedMcpProblems.Clear(); + _mcpSignInsInProgress.Clear(); + } + _announcedWorkspaceKey = null; + lock (_sessionConnectors) _sessionConnectors.Clear(); + _sessionWorkingDirectory = null; + } + + private async Task SyncWorkspaceAsync(CopilotSession session, bool updateSkills, CancellationToken token) + { + try + { + await _workspaceGate.WaitAsync(token); + try + { + if (!ReferenceEquals(_session, session) || _sessionWorkingDirectory is not { } workingDirectory) + return; + + if (updateSkills) + { + var roots = await GetOpenProjectRootsAsync(); + await session.Rpc.Options.UpdateAsync( + skillDirectories: BuildSessionSkillDirectories(workingDirectory, roots), + cancellationToken: token); + await session.Rpc.Skills.ReloadAsync(token); + } + } + finally + { + _workspaceGate.Release(); + } + } + catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException) + { + // The session was closed or replaced meanwhile. + } + catch (Exception e) + { + Logger.LogWarning(e, "Failed to apply the open projects to the Copilot session."); + } + } + + // While connectors are reapplied, the configured definitions start briefly without the stored values. + private bool IsApplyingConnector(string name) + { + if (!_applyingConnectors) return false; + lock (_sessionConnectors) return _sessionConnectors.ContainsKey(name); + } + + /// Shows an MCP problem once per chat. + private bool TryReportMcpProblem(string key) + { + lock (_reportedMcpProblems) return _reportedMcpProblems.Add(key); + } + + private void HandleMcpServerStatusChanged(SessionMcpServerStatusChangedData data) + { + if (_session is not { } session) return; + + if (data.Status == McpServerStatus.NeedsAuth) + { + OfferMcpSignIn(session, data.ServerName); + return; + } + + bool signingIn; + lock (_reportedMcpProblems) signingIn = _mcpSignInsInProgress.Remove(data.ServerName); + + if (data.Status == McpServerStatus.Connected) + { + if (signingIn) + EventReceived?.Invoke(this, new ChatMessageEvent($"Signed in to MCP server `{data.ServerName}`.")); + } + else if (data.Status == McpServerStatus.Failed && !IsApplyingConnector(data.ServerName) && + (signingIn || TryReportMcpProblem($"failed:{data.ServerName}"))) + { + Logger.LogWarning("MCP server {Name} failed: {Error}", data.ServerName, data.Error); + EventReceived?.Invoke(this, new ChatErrorEvent( + $"MCP server '{data.ServerName}' failed: {data.Error ?? "unknown error"}. Check it in " + + "Settings › AI Chat › Connectors.")); + } + } + + /// + /// Asks the user once per chat to sign in to a remote MCP server that requires OAuth. The runtime runs the flow + /// and keeps the tokens in its keychain storage, so every chat can use them afterwards. + /// + private void OfferMcpSignIn(CopilotSession session, string serverName) + { + if (!TryReportMcpProblem($"auth:{serverName}")) return; + + var message = $"**MCP server `{serverName}` requires you to sign in.**\n\n" + + "The sign-in page opens in your browser. The access token is kept by the Copilot CLI " + + "in your system keychain."; + EventReceived?.Invoke(this, new ChatPermissionRequestEvent(message, "Sign in", "Not now", + new RelayCommand(_ => SignInToMcpServerAsync(session, serverName)), + new RelayCommand(_ => { }))); + } + + private async Task SignInToMcpServerAsync(CopilotSession session, string serverName) + { + var connectors = ContainerLocator.Container.Resolve(); + try + { + McpConnector? connector = null; + try + { + connector = (await connectors.ListAsync()).FirstOrDefault(x => x.Name == serverName); + } + catch (Exception e) + { + Logger.LogDebug(e, "Failed to look up connector {Name}.", serverName); + } + + if (connector != null) + { + // Signs in on a separate runtime with the connector's OAuth app; open chats reconnect afterwards. + var status = await connectors.SignInAsync(connector); + if (status.State == McpConnectorState.Connected) + EventReceived?.Invoke(this, new ChatMessageEvent($"Signed in to MCP server `{serverName}`.")); + else + EventReceived?.Invoke(this, new ChatErrorEvent( + $"Signing in to MCP server '{serverName}' failed: {status.Message ?? "not completed"}")); + return; + } + + // A server of the project's own configuration. + lock (_reportedMcpProblems) _mcpSignInsInProgress.Add(serverName); + var result = await session.Rpc.Mcp.Oauth.LoginAsync(new McpOauthLoginRequest + { + ServerName = serverName, + ClientName = "OneWare Studio", + CallbackSuccessMessage = "Signed in. You can close this tab and return to OneWare Studio." + }); + + // Without a URL cached tokens were still valid and the server is already reconnected. + if (!string.IsNullOrEmpty(result.AuthorizationUrl)) + PlatformHelper.OpenHyperLink(result.AuthorizationUrl); + } + catch (Exception e) + { + lock (_reportedMcpProblems) _mcpSignInsInProgress.Remove(serverName); + Logger.LogWarning(e, "Failed to sign in to MCP server {Name}.", serverName); + EventReceived?.Invoke(this, new ChatErrorEvent( + $"Signing in to MCP server '{serverName}' failed: " + + CopilotConnectorService.DescribeSignInError(e.Message))); + } + } + + /// + /// Prepends the open projects and their instruction files whenever they differ from what this session was + /// last told, so instructions of projects besides the working directory apply without bloating every turn. + /// + private async Task ApplyWorkspaceContextAsync(string prompt) + { + if (_sessionWorkingDirectory is not { } workingDirectory) return prompt; + + try + { + var roots = await GetOpenProjectRootsAsync(); + string? key = null; + var text = await Task.Run(() => + CopilotSessionConfiguration.BuildWorkspaceContext(roots, workingDirectory, out key)); + + if (string.Equals(key, _announcedWorkspaceKey, StringComparison.Ordinal)) return prompt; + _announcedWorkspaceKey = key; + + return text == null ? prompt : $"{text}\n\n{prompt}"; + } + catch (Exception e) + { + Logger.LogWarning(e, "Failed to describe the open projects to the Copilot session."); + return prompt; + } + } +} diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index 47709fef..93d23b68 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -20,6 +20,7 @@ using GitHub.Copilot.Rpc; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using OneWare.Copilot.Connectors; using OneWare.Copilot.ViewModels; using OneWare.Copilot.Views; using OneWare.Essentials.Enums; @@ -39,7 +40,7 @@ public sealed record ContextSizeOption(string Label, string Tier, long Tokens) public override string ToString() => Label; } -public abstract class CopilotChatServiceBase( +public abstract partial class CopilotChatServiceBase( ISettingsService settingsService, IAiFunctionProvider toolProvider, IPackageService packageService, @@ -1210,6 +1211,7 @@ private async Task RunSignOutAsync() private bool _attachmentTrackingInitialized; private bool _activeFileDismissed; + private IExtendedDocument? _activeDocument; private IEditor? _trackedEditor; // The active file is only sent when text is selected in it, unless the user switched the chip. @@ -1249,42 +1251,53 @@ private void OnEditorSelectionChanged(object? sender, EventArgs e) => private void RefreshActiveFileAttachment(bool focusChanged) { - var currentDocument = mainDockService.CurrentDocument; - - if (currentDocument is IEditor editor && !ReferenceEquals(currentDocument, _trackedEditor)) + try { - if (_trackedEditor != null) - _trackedEditor.Editor.TextArea.SelectionChanged -= OnEditorSelectionChanged; + var currentDocument = mainDockService.CurrentDocument; - _trackedEditor = editor; + if (!ReferenceEquals(currentDocument, _activeDocument)) + { + _activeDocument = currentDocument; + TrackSelection(currentDocument as IEditor); - if (_trackedEditor != null) - _trackedEditor.Editor.TextArea.SelectionChanged += OnEditorSelectionChanged; + // Moving focus to a different file revives a previously dismissed active-file chip. + if (focusChanged) + { + _activeFileDismissed = false; + _activeFileIncludedOverride = null; + } + } - // Moving focus to a different file revives a previously dismissed active-file chip. - if (focusChanged) + var attachment = _activeFileDismissed ? null : BuildActiveFileAttachment(currentDocument); + var hasSelection = attachment?.Detail != null; + if (hasSelection != _activeFileHadSelection) { - _activeFileDismissed = false; + _activeFileHadSelection = hasSelection; _activeFileIncludedOverride = null; } - } - var attachment = _activeFileDismissed ? null : BuildActiveFileAttachment(currentDocument); - var hasSelection = attachment?.Detail != null; - if (hasSelection != _activeFileHadSelection) - { - _activeFileHadSelection = hasSelection; - _activeFileIncludedOverride = null; - } + if (attachment != null) + { + attachment.IsIncluded = _activeFileIncludedOverride ?? hasSelection; + attachment.OnToggled(a => _activeFileIncludedOverride = a.IsIncluded); + } - if (attachment != null) + var previous = ActiveFileAttachment; + ActiveFileAttachment = attachment; + previous?.Dispose(); + } + catch (Exception e) { - attachment.IsIncluded = _activeFileIncludedOverride ?? hasSelection; - attachment.OnToggled(a => _activeFileIncludedOverride = a.IsIncluded); + ContainerLocator.Container.Resolve().LogWarning(e, "Failed to update the active file attachment."); } + } - ActiveFileAttachment?.Dispose(); - ActiveFileAttachment = attachment; + private void TrackSelection(IEditor? editor) + { + if (ReferenceEquals(editor, _trackedEditor)) return; + if (_trackedEditor != null) _trackedEditor.Editor.TextArea.SelectionChanged -= OnEditorSelectionChanged; + _trackedEditor = editor; + if (_trackedEditor != null) _trackedEditor.Editor.TextArea.SelectionChanged += OnEditorSelectionChanged; } private CopilotAttachmentViewModel? BuildActiveFileAttachment(IExtendedDocument? document) @@ -1600,11 +1613,15 @@ public async Task InitializeAsync() } } + // MCP servers inherit the runtime's environment; the login shell's PATH lets them find npx and co. + var connection = RuntimeConnection.ForStdio(cliPath, []); + connection.Environment = await CopilotConnectorService.GetRuntimeEnvironmentAsync(); + var clientOptions = new CopilotClientOptions { WorkingDirectory = paths.ProjectsDirectory, ClientInfo = BuildClientInfo(), - Connection = RuntimeConnection.ForStdio(cliPath, []), + Connection = connection, UseLoggedInUser = _byokConfiguration == null }; @@ -2017,10 +2034,15 @@ private async Task InitializeSessionCoreAsync(string? sessionId) // Plugins can register tools at any time, so the cache is rebuilt per session. _clientToolNames = null; + var workingDirectory = ContainerLocator.Container.Resolve() + .ActiveProject?.RootFolderPath ?? paths.ProjectsDirectory; + _sessionWorkingDirectory = workingDirectory; + var skillDirectories = BuildSessionSkillDirectories(workingDirectory, await GetOpenProjectRootsAsync()); + var connectorServers = await BuildConnectorServersAsync(); if (string.IsNullOrWhiteSpace(sessionId)) { - var tools = toolProvider.GetTools().Cast().ToList(); + var tools = CopilotSessionConfiguration.ApplyToolDeferral(toolProvider.GetTools()); var sessionConfig = new SessionConfig { Model = SelectedModel.Id, @@ -2033,14 +2055,9 @@ private async Task InitializeSessionCoreAsync(string? sessionId) IncludeSubAgentStreamingEvents = true, SystemMessage = BuildSystemMessageConfig(), Tools = tools, - // Restrict the session to OneWare's own tools plus the session-isolated built-ins - // (task/skill delegation, ask_user, …) that plugin-contributed agents and skills - // need. Host-capable built-ins stay unavailable. - AvailableTools = BuildAvailableTools(), + AvailableTools = CopilotSessionConfiguration.BuildAvailableTools(), ExcludedTools = ExcludedBuiltInTools.ToList(), CustomAgents = BuildCustomAgents(), - SkillDirectories = BuildSkillDirectories(), - EnableSkills = true, ClientName = "OneWare Studio", OnPermissionRequest = OnPermissionRequestAsync, OnUserInputRequest = OnUserInputRequestAsync, @@ -2051,6 +2068,8 @@ private async Task InitializeSessionCoreAsync(string? sessionId) } }; + CopilotSessionConfiguration.ApplyDiscovery(sessionConfig, workingDirectory, skillDirectories); + sessionConfig.McpServers = connectorServers; _session = await client.CreateSessionAsync(sessionConfig); } else @@ -2062,19 +2081,17 @@ private async Task InitializeSessionCoreAsync(string? sessionId) // A session can only be tracked once per client: an unloaded chat may still hold it. await ReleaseSessionHeldByOthersAsync(sessionId); - _session = await client.ResumeSessionAsync(sessionId, new ResumeSessionConfig() + var resumeConfig = new ResumeSessionConfig() { Streaming = true, Provider = BuildProviderConfig(), ContextTier = ResolveContextTier(), IncludeSubAgentStreamingEvents = true, SystemMessage = BuildSystemMessageConfig(), - Tools = toolProvider.GetTools().Cast().ToList(), - AvailableTools = BuildAvailableTools(), + Tools = CopilotSessionConfiguration.ApplyToolDeferral(toolProvider.GetTools()), + AvailableTools = CopilotSessionConfiguration.BuildAvailableTools(), ExcludedTools = ExcludedBuiltInTools.ToList(), CustomAgents = BuildCustomAgents(), - SkillDirectories = BuildSkillDirectories(), - EnableSkills = true, OnPermissionRequest = OnPermissionRequestAsync, OnUserInputRequest = OnUserInputRequestAsync, OnExitPlanModeRequest = OnExitPlanModeRequestAsync, @@ -2082,7 +2099,10 @@ private async Task InitializeSessionCoreAsync(string? sessionId) { OnPreToolUse = OnPreToolUseAsync } - }); + }; + CopilotSessionConfiguration.ApplyDiscovery(resumeConfig, workingDirectory, skillDirectories); + resumeConfig.McpServers = connectorServers; + _session = await client.ResumeSessionAsync(sessionId, resumeConfig); } finally { @@ -2091,6 +2111,7 @@ private async Task InitializeSessionCoreAsync(string? sessionId) } CurrentSessionId = _session?.SessionId ?? sessionId; + if (_session != null) _ = ApplyConnectorsAsync(_session, reload: false); var sdkModelState = string.IsNullOrWhiteSpace(sessionId) || storedModelState != null ? null : await TryGetSdkModelStateAsync(); @@ -2116,6 +2137,9 @@ private async Task InitializeSessionCoreAsync(string? sessionId) _toolsChangedSubscribed = true; } + SubscribeToProjectChanges(); + _ = StartWorkspaceSync(_session, updateSkills: false); + if (string.IsNullOrWhiteSpace(sessionId)) { // The routing preference is not part of the session config, so it has to be applied to @@ -2131,22 +2155,19 @@ private async Task InitializeSessionCoreAsync(string? sessionId) } /// - /// Allow-list for the session: every tool OneWare registers, plus the built-ins that operate - /// only inside the session (agent delegation via task, skill loading, …). Built-ins - /// with host filesystem, shell or network access stay out, because - /// auto-approves any tool that has no OneWare confirmation check. + /// Converts the agents contributed by modules/plugins (e.g. a dataset agent from an FPGA AI + /// extension) into Copilot custom agents the main agent can delegate to. /// - private ToolSet BuildAvailableTools() + private IList BuildSessionSkillDirectories(string workingDirectory, IReadOnlyList projectRoots) { - return new ToolSet() - .AddCustom("*") - .AddBuiltIn(BuiltInTools.Isolated); + return CopilotSessionConfiguration.BuildSkillDirectories( + toolProvider.GetSkillDirectories() + .Concat(CopilotSessionConfiguration.GetProjectSkillDirectories(projectRoots, workingDirectory)), + settingsService.GetSettingValue>(CopilotModule.CopilotSkillDirectoriesSettingKey), + workingDirectory, + message => ContainerLocator.Container.Resolve().LogWarning("{Message}", message)); } - /// - /// Converts the agents contributed by modules/plugins (e.g. a dataset agent from an FPGA AI - /// extension) into Copilot custom agents the main agent can delegate to. - /// private IList BuildCustomAgents() { return toolProvider.GetAgents() @@ -2165,11 +2186,6 @@ private IList BuildCustomAgents() .ToList(); } - private IList BuildSkillDirectories() - { - return toolProvider.GetSkillDirectories().ToList(); - } - private SystemMessageConfig BuildSystemMessageConfig() { var overrides = new Dictionary { @@ -2189,12 +2205,16 @@ private SystemMessageConfig BuildSystemMessageConfig() { Content = """ OneWare Studio IDE context tools — use these instead of assumptions: - - Active file path → `getFocusedFile` - Open file paths → `getOpenFiles` - Active project → `getActiveProject` - LSP diagnostics → `getErrorsForFile` or `getAllErrors` + The file the user is working on (and any selection) arrives as a message attachment + when the user includes it. PATH RULES: always pass ABSOLUTE paths to file tools; take paths verbatim from tool outputs. + Follow the repository's AGENTS.md instructions. Before working in a subdirectory, + use `readFile` to check for more specific AGENTS.md files along that directory's + path and follow their scope. OneWare file tools do not use the CLI's built-in viewer. """ }, @@ -2300,7 +2320,7 @@ public async Task SendAsync(string prompt, ChatSendMode mode) var options = new MessageOptions { - Prompt = ApplyAgentInstructions(prompt, agent), + Prompt = await ApplyWorkspaceContextAsync(ApplyAgentInstructions(prompt, agent)), AgentMode = agent?.TurnMode switch { ChatAgentTurnMode.Plan => AgentMode.Plan, @@ -2431,6 +2451,7 @@ public async ValueTask DisposeAsync() _toolsChangedSubscribed = false; } Interlocked.Exchange(ref _toolsRefreshCts, null)?.Cancel(); + UnsubscribeFromProjectChanges(); ReleasePendingInputRequests(); ReleasePendingPlanRequests(); @@ -2489,6 +2510,7 @@ private async Task DisposeSessionAsync() { _subscription?.Dispose(); _subscription = null; + ResetWorkspaceState(); if (_session != null) { @@ -2547,6 +2569,11 @@ private void HandleSessionEvent(SessionEvent evt) switch (evt) { + case SessionMcpServerStatusChangedEvent x: + { + HandleMcpServerStatusChanged(x.Data); + break; + } case SubagentStartedEvent x: { HandleSubAgentStarted(x); @@ -2632,6 +2659,13 @@ private void HandleSessionEvent(SessionEvent evt) }); break; } + case SessionWarningEvent { Data.WarningType: "compaction_static_context_blocked" } warning + when agentId == null: + // Followed by a generic "No response was returned" error that doesn't say why. + EventReceived?.Invoke(this, new ChatErrorEvent( + $"{warning.Data.Message} Disable connectors with many tools in Settings › AI Chat › Connectors " + + "or choose a model with a larger context.")); + break; case SessionErrorEvent error: if (HandleSessionError(error, agentId)) break; @@ -2895,7 +2929,7 @@ private async Task RefreshSessionToolsAsync(CancellationToken cancellationToken) if (session == null) return; _clientToolNames = null; - var tools = toolProvider.GetTools().Cast().ToList(); + var tools = CopilotSessionConfiguration.ApplyToolDeferral(toolProvider.GetTools()); await session.SetToolsAsync(tools, cancellationToken); } finally @@ -3148,24 +3182,21 @@ private static string ApplyAgentInstructions(string prompt, ChatAgentDefinition? /// /// Returns why the selected agent must not call the given tool, or if it - /// may. Only OneWare tools can reach the workspace or the IDE — backend built-ins that touch the - /// host are already kept out of the session by — so a read-only - /// agent is enforced by blocking every OneWare tool that is not marked read-only. + /// may. Read-only agents cannot run external tools whose side effects OneWare cannot classify. /// private string? GetAgentToolDenyReason(string toolName) { var agent = agentService.SelectedAgent; if (agent == null) return null; - // Only tools OneWare provides are restricted: the session built-ins (task/skill delegation, - // planning bookkeeping, …) are what the agent works with and are never named in a tool list. var isOneWareTool = toolProvider.IsFunctionReadOnly(toolName); - if (isOneWareTool == null) return null; + if (isOneWareTool == null && + !CopilotSessionConfiguration.IsExternalTool(toolName, IsClientTool(toolName))) return null; if (agent.Tools != null && !agent.Tools.Contains(toolName, StringComparer.OrdinalIgnoreCase)) return $"The {agent.DisplayName} agent is not allowed to use '{toolName}'."; - if (agent.IsReadOnly && isOneWareTool == false) + if (agent.IsReadOnly && isOneWareTool != true) return $"'{toolName}' changes the workspace, which is not allowed in {agent.DisplayName} mode. " + "Report what you would change instead, or ask the user to switch to Agent mode."; @@ -3273,6 +3304,11 @@ private sealed class PendingPlanRequest if (IsApprovalBypassed) return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }); + // MCP tools must retain the runtime's permission checks rather than inheriting the + // automatic approval of OneWare tools without a confirmation check. + if (CopilotSessionConfiguration.IsExternalTool(input.ToolName, IsClientTool(input.ToolName))) + return Task.FromResult(null); + var check = toolProvider.GetConfirmationCheck(input.ToolName); if (check == null) return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }); @@ -3308,6 +3344,10 @@ private Task OnPermissionRequestAsync( PermissionRequest request, PermissionInvocation invocation) { + if (request is PermissionRequestMcp && agentService.SelectedAgent is { IsReadOnly: true } agent) + return Task.FromResult(PermissionDecision.Reject( + $"MCP tools are not allowed in {agent.DisplayName} mode because their side effects are unknown.")); + // Bypass Approval / Autopilot: auto-approve all permission requests if (IsApprovalBypassed) return Task.FromResult(PermissionDecision.ApproveOnce()); diff --git a/src/OneWare.Copilot/Services/CopilotSessionConfiguration.cs b/src/OneWare.Copilot/Services/CopilotSessionConfiguration.cs new file mode 100644 index 00000000..4fe873fb --- /dev/null +++ b/src/OneWare.Copilot/Services/CopilotSessionConfiguration.cs @@ -0,0 +1,195 @@ +using System.IO; +using System.Text; +using GitHub.Copilot; +using Microsoft.Extensions.AI; + +namespace OneWare.Copilot.Services; + +internal static class CopilotSessionConfiguration +{ + internal static readonly StringComparer PathComparer = + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + private static readonly string[] ProjectSkillFolders = + [Path.Combine(".github", "skills"), Path.Combine(".agents", "skills"), Path.Combine(".claude", "skills")]; + + private static readonly string[] ProjectInstructionFiles = + ["AGENTS.md", Path.Combine(".github", "copilot-instructions.md")]; + + internal const int MaxInstructionFileChars = 32_000; + + /// + /// Project skill roots of the open projects. The runtime only discovers those of the session's working + /// directory itself, so that project is skipped to avoid loading its skills twice. + /// + internal static IEnumerable GetProjectSkillDirectories(IEnumerable projectRoots, + string workingDirectory) + { + return projectRoots + .Where(root => !PathComparer.Equals(Path.GetFullPath(root), Path.GetFullPath(workingDirectory))) + .SelectMany(root => ProjectSkillFolders.Select(folder => Path.Combine(root, folder))) + .Where(Directory.Exists); + } + + /// + /// Describes the open projects and inlines the instruction files of those the runtime does not load itself + /// (every project except the working directory). Returns when there is nothing beyond + /// what the runtime already knows. changes whenever the context would change. + /// + internal static string? BuildWorkspaceContext(IReadOnlyList projectRoots, string workingDirectory, + out string key) + { + var workingRoot = Path.GetFullPath(workingDirectory); + var keyBuilder = new StringBuilder(workingRoot); + var others = projectRoots.Where(x => !PathComparer.Equals(Path.GetFullPath(x), workingRoot)).ToList(); + + if (others.Count == 0) + { + key = keyBuilder.ToString(); + return null; + } + + var text = new StringBuilder(); + text.AppendLine(""); + text.AppendLine($"Your working directory is {workingRoot}. The IDE also has these projects open:"); + foreach (var root in projectRoots) + { + var marker = PathComparer.Equals(Path.GetFullPath(root), workingRoot) ? " (working directory)" : ""; + text.AppendLine($"- {Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))}: {root}{marker}"); + } + + text.AppendLine("Follow each project's instructions below when working in it, and read more specific " + + "AGENTS.md files in its subdirectories before working there."); + + foreach (var root in others) + { + keyBuilder.Append('|').Append(root); + foreach (var file in ProjectInstructionFiles.Select(x => Path.Combine(root, x))) + { + if (!File.Exists(file)) continue; + + string content; + try + { + var info = new FileInfo(file); + keyBuilder.Append('|').Append(file).Append(':').Append(info.Length).Append(':') + .Append(info.LastWriteTimeUtc.Ticks); + content = File.ReadAllText(file).Trim(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + continue; + } + + if (content.Length == 0) continue; + if (content.Length > MaxInstructionFileChars) + content = content[..MaxInstructionFileChars] + + $"\n[truncated, read {file} for the rest]"; + + text.AppendLine($""); + text.AppendLine(content); + text.AppendLine(""); + } + } + + text.Append(""); + key = keyBuilder.ToString(); + return text.ToString(); + } + internal static string GetConfigDirectory() + { + var copilotHome = Environment.GetEnvironmentVariable("COPILOT_HOME"); + return Path.GetFullPath(string.IsNullOrWhiteSpace(copilotHome) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot") + : copilotHome); + } + + internal static void ApplyDiscovery(SessionConfigBase config, string workingDirectory, + IList skillDirectories) + { + config.WorkingDirectory = workingDirectory; + // BYOK runtimes have separate state directories, but use the same user customizations. + config.ConfigDirectory = GetConfigDirectory(); + config.EnableConfigDiscovery = true; + config.EnableOnDemandInstructionDiscovery = true; + config.EnableSkills = true; + config.SkillDirectories = skillDirectories; + // Discovering user customizations must not opt into executing repository hooks. + config.EnableFileHooks = false; + // Keep MCP sign-ins (connectors) in the OS keychain instead of asking again in every chat. + config.McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent; + // Connectors can add hundreds of tools (Microsoft 365 alone ~400), more than fits into the model's context. + // Tool search keeps them out of the prompt until the model looks them up. + config.ToolSearch = new ToolSearchConfig { Enabled = true }; + } + + internal static IList BuildSkillDirectories(IEnumerable pluginDirectories, + IEnumerable userDirectories, string workingDirectory, Action warn) + { + var comparer = PathComparer; + var directories = new HashSet(comparer); + foreach (var entry in pluginDirectories.Concat(userDirectories)) + { + if (string.IsNullOrWhiteSpace(entry)) continue; + + var directory = ResolveUserPath(entry, workingDirectory); + if (Directory.Exists(directory)) + directories.Add(directory); + else + warn($"Skill directory does not exist: {directory}"); + } + + return directories.ToList(); + } + + /// Resolves a path from the settings: absolute, ~/ or relative to the working directory. + internal static string ResolveUserPath(string entry, string workingDirectory) + { + var path = entry.Trim(); + if (path == "~" || path.StartsWith("~/", StringComparison.Ordinal) || + path.StartsWith(@"~\", StringComparison.Ordinal)) + path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + path.Length == 1 ? "" : path[2..]); + + return Path.GetFullPath(path, workingDirectory); + } + + /// + /// OneWare's core tools (from OneWare.Chat/Services/AiBuiltInFunctions.cs), always sent to the model. + /// They replace the runtime's view, create, edit and bash or give the IDE context + /// almost every request needs. All other tools (plugins, MCP servers) may be deferred behind tool search. + /// + internal static readonly IReadOnlySet AlwaysLoadedTools = new HashSet(StringComparer.Ordinal) + { + "readFile", + "editFile", + "runTerminalCommand", + "getErrorsForFile", + "getAllErrors", + "getActiveProject", + "getOpenFiles", + "openFile" + }; + + /// Marks the so tool search never defers them. + internal static List ApplyToolDeferral(IEnumerable tools) => + tools.Select(AIFunctionDeclaration (x) => AlwaysLoadedTools.Contains(x.Name) ? new AlwaysLoadedTool(x) : x) + .ToList(); + + // Same as CopilotTool.DefineTool with CopilotToolOptions.Defer = Never, for an existing function. + private sealed class AlwaysLoadedTool(AIFunction inner) : DelegatingAIFunction(inner) + { + private const string DeferKey = "defer"; + + public override IReadOnlyDictionary AdditionalProperties { get; } = + new Dictionary(inner.AdditionalProperties) { [DeferKey] = CopilotToolDefer.Never }; + } + + internal static ToolSet BuildAvailableTools() => new ToolSet() + .AddCustom("*") + .AddMcp("*") + .AddBuiltIn(BuiltInTools.Isolated); + + internal static bool IsExternalTool(string toolName, bool isClientTool) => + !isClientTool && !BuiltInTools.Isolated.Contains(toolName, StringComparer.Ordinal); +} diff --git a/src/OneWare.Copilot/Services/LoginShellEnvironment.cs b/src/OneWare.Copilot/Services/LoginShellEnvironment.cs new file mode 100644 index 00000000..3b1c414b --- /dev/null +++ b/src/OneWare.Copilot/Services/LoginShellEnvironment.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; +using System.IO; + +namespace OneWare.Copilot.Services; + +/// +/// The environment of the user's login shell. Apps started from a desktop launcher don't see variables exported +/// in shell profiles (tokens referenced by MCP configurations, for example), so they are read from there like +/// VS Code does. Read once per process, never on Windows. +/// +internal static class LoginShellEnvironment +{ + private const string Marker = "__ONEWARE_SHELL_ENV__"; + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + private static Task>? _environment; + + public static Task> GetAsync() => + LazyInitializer.EnsureInitialized(ref _environment, () => Task.Run(ReadAsync)); + + private static async Task> ReadAsync() + { + var empty = new Dictionary(); + if (OperatingSystem.IsWindows() || OperatingSystem.IsBrowser()) return empty; + + var shell = ResolveShell(Environment.GetEnvironmentVariable("SHELL"), + Environment.GetEnvironmentVariable("PATH"), ReadAccountShell); + + try + { + var start = new ProcessStartInfo(shell) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + // Interactive and login, so both ~/.profile and ~/.bashrc / ~/.zshrc style files are applied. + foreach (var arg in new[] { "-i", "-l", "-c", $"printf '{Marker}'; env -0" }) + start.ArgumentList.Add(arg); + + using var process = Process.Start(start); + if (process == null) return empty; + process.StandardInput.Close(); + + using var cts = new CancellationTokenSource(Timeout); + var output = process.StandardOutput.ReadToEndAsync(cts.Token); + _ = process.StandardError.ReadToEndAsync(cts.Token); + try + { + await process.WaitForExitAsync(cts.Token); + return Parse(await output); + } + catch (OperationCanceledException) + { + process.Kill(true); + return empty; + } + } + catch (Exception) + { + return empty; + } + } + + /// + /// The user's shell. SHELL may be a bare name (some launchers and IDE run configurations set + /// SHELL=bash) or missing; falling back to /bin/sh right away would skip ~/.bashrc / + /// ~/.zshrc, where tools such as nvm are set up. + /// + internal static string ResolveShell(string? shell, string? path, Func accountShell) + { + if (FindExecutable(shell, path) is { } fromVariable) return fromVariable; + if (FindExecutable(accountShell(), path) is { } fromAccount) return fromAccount; + return "/bin/sh"; + } + + private static string? FindExecutable(string? name, string? path) + { + if (string.IsNullOrWhiteSpace(name)) return null; + name = name.Trim(); + if (Path.IsPathRooted(name)) return File.Exists(name) ? name : null; + if (name.Contains('/')) return null; + + return (path ?? string.Empty).Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Concat(["/usr/bin", "/bin"]) + .Select(x => Path.Combine(x, name)) + .FirstOrDefault(File.Exists); + } + + // The login shell of the account, from /etc/passwd. + private static string? ReadAccountShell() + { + try + { + var user = Environment.UserName; + return File.ReadLines("/etc/passwd") + .Select(x => x.Split(':')) + .FirstOrDefault(x => x.Length >= 7 && x[0] == user)?[6]; + } + catch (Exception) + { + return null; + } + } + + internal static IReadOnlyDictionary Parse(string output) + { + var result = new Dictionary(StringComparer.Ordinal); + + // Profiles may print banners before the marker. + var start = output.LastIndexOf(Marker, StringComparison.Ordinal); + if (start < 0) return result; + + foreach (var entry in output[(start + Marker.Length)..].Split('\0')) + { + var separator = entry.IndexOf('='); + if (separator <= 0) continue; + result[entry[..separator]] = entry[(separator + 1)..]; + } + + return result; + } +} diff --git a/src/OneWare.Copilot/Settings/CopilotConnectorsSetting.cs b/src/OneWare.Copilot/Settings/CopilotConnectorsSetting.cs new file mode 100644 index 00000000..cc7ff939 --- /dev/null +++ b/src/OneWare.Copilot/Settings/CopilotConnectorsSetting.cs @@ -0,0 +1,14 @@ +using OneWare.Copilot.Connectors; +using OneWare.Copilot.ViewModels; +using OneWare.Essentials.Models; + +namespace OneWare.Copilot.Settings; + +/// Settings entry for the connectors; they are stored in the Copilot CLI configuration, not here. +public sealed class CopilotConnectorsSetting : CustomSetting +{ + public CopilotConnectorsSetting(CopilotConnectorService service) : base(string.Empty) + { + Control = new CopilotConnectorsSettingViewModel(service); + } +} diff --git a/src/OneWare.Copilot/ViewModels/CopilotConnectorEditorViewModel.cs b/src/OneWare.Copilot/ViewModels/CopilotConnectorEditorViewModel.cs new file mode 100644 index 00000000..7c1c25ab --- /dev/null +++ b/src/OneWare.Copilot/ViewModels/CopilotConnectorEditorViewModel.cs @@ -0,0 +1,511 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OneWare.Copilot.Connectors; +using OneWare.Essentials.Controls; +using OneWare.Essentials.ViewModels; + +namespace OneWare.Copilot.ViewModels; + +/// Adds or edits a connector. Values marked as secret are kept in the OS credential store. +public sealed class CopilotConnectorEditorViewModel : FlexibleWindowViewModelBase +{ + private readonly CopilotConnectorService _service; + private readonly McpConnector? _original; + private readonly HashSet _otherNames; + private readonly IReadOnlySet _storedVariables; + private string _name = string.Empty; + private int _kindIndex; + private string _command = string.Empty; + private string _arguments = string.Empty; + private string _workingDirectory = string.Empty; + private string _url = string.Empty; + private bool _useSse; + private string _oauthClientId = string.Empty; + private string _oauthClientSecret = string.Empty; + private bool _hasStoredOAuthClientSecret; + private bool _clearOAuthClientSecret; + private string? _error; + private bool _isSaving; + + private CopilotConnectorEditorViewModel(CopilotConnectorService service, McpConnector? original, + IEnumerable otherNames, IReadOnlySet storedVariables) + { + _service = service; + _original = original; + // Names with the same variable key would share stored values. + _otherNames = otherNames.Select(McpConnector.GetVariableKey).ToHashSet(StringComparer.Ordinal); + _storedVariables = storedVariables; + Title = original == null ? "Add Connector" : $"Edit Connector - {original.Name}"; + + AddEnvCommand = new RelayCommand(() => AddValue(Env, new McpConnectorValue("", ""), false, false)); + AddHeaderCommand = new RelayCommand(() => AddValue(Headers, new McpConnectorValue("", ""), false, false)); + ClearOAuthClientSecretCommand = new RelayCommand(() => + { + ClearOAuthClientSecret = true; + OAuthClientSecret = string.Empty; + }); + SaveCommand = new AsyncRelayCommand(SaveAsync); + CancelCommand = new RelayCommand(x => x?.Close()); + + if (original == null) return; + + _name = original.Name; + _kindIndex = original.Kind == McpConnectorKind.Remote ? 1 : 0; + _command = original.Command; + _arguments = string.Join(Environment.NewLine, original.Args); + _workingDirectory = original.WorkingDirectory ?? string.Empty; + _url = original.Url; + _useSse = original.UseSse; + _oauthClientId = original.OAuthClientId ?? string.Empty; + + foreach (var value in original.Env) AddLoadedValue(Env, value); + foreach (var value in original.Headers) AddLoadedValue(Headers, value); + } + + public static async Task CreateAsync(CopilotConnectorService service, + McpConnector? connector, IEnumerable otherNames) + { + var stored = await service.GetStoredVariablesAsync(); + var editor = new CopilotConnectorEditorViewModel(service, connector?.Clone(), otherNames, stored); + if (connector != null) + editor.HasStoredOAuthClientSecret = await service.HasOAuthClientSecretAsync(connector.Name); + editor.UpdateVariables(); + return editor; + } + + public bool Saved { get; private set; } + + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + /// 0 = local command, 1 = remote URL. + public int KindIndex + { + get => _kindIndex; + set + { + if (!SetProperty(ref _kindIndex, value)) return; + OnPropertyChanged(nameof(IsLocal)); + OnPropertyChanged(nameof(IsRemote)); + UpdateVariables(); + } + } + + public bool IsLocal => KindIndex == 0; + + public bool IsRemote => KindIndex == 1; + + public string Command + { + get => _command; + set + { + if (SetProperty(ref _command, value)) UpdateVariables(); + } + } + + /// One argument per line. + public string Arguments + { + get => _arguments; + set + { + if (SetProperty(ref _arguments, value)) UpdateVariables(); + } + } + + public string WorkingDirectory + { + get => _workingDirectory; + set + { + if (SetProperty(ref _workingDirectory, value)) UpdateVariables(); + } + } + + public string Url + { + get => _url; + set => SetProperty(ref _url, value); + } + + public bool UseSse + { + get => _useSse; + set => SetProperty(ref _useSse, value); + } + + public string OAuthClientId + { + get => _oauthClientId; + set => SetProperty(ref _oauthClientId, value); + } + + public string OAuthClientSecret + { + get => _oauthClientSecret; + set + { + if (SetProperty(ref _oauthClientSecret, value) && value.Length > 0) ClearOAuthClientSecret = false; + } + } + + public bool HasStoredOAuthClientSecret + { + get => _hasStoredOAuthClientSecret; + private set + { + if (SetProperty(ref _hasStoredOAuthClientSecret, value)) + OnPropertyChanged(nameof(OAuthClientSecretWatermark)); + } + } + + public bool ClearOAuthClientSecret + { + get => _clearOAuthClientSecret; + private set + { + if (SetProperty(ref _clearOAuthClientSecret, value)) + OnPropertyChanged(nameof(OAuthClientSecretWatermark)); + } + } + + public string OAuthClientSecretWatermark => HasStoredOAuthClientSecret && !ClearOAuthClientSecret + ? "Stored - leave empty to keep" + : "Only for confidential apps"; + + public string RedirectUrl => CopilotConnectorService.OAuthRedirectUrl; + + public ObservableCollection Env { get; } = []; + + public ObservableCollection Headers { get; } = []; + + /// Variables referenced with ${VAR} that are not secret values of this editor. + public ObservableCollection Variables { get; } = []; + + public bool HasVariables => Variables.Count > 0; + + public string? Error + { + get => _error; + private set => SetProperty(ref _error, value); + } + + public bool IsSaving + { + get => _isSaving; + private set => SetProperty(ref _isSaving, value); + } + + public RelayCommand AddEnvCommand { get; } + + public RelayCommand AddHeaderCommand { get; } + + public RelayCommand ClearOAuthClientSecretCommand { get; } + + public AsyncRelayCommand SaveCommand { get; } + + public RelayCommand CancelCommand { get; } + + private void AddLoadedValue(ObservableCollection target, McpConnectorValue value) + { + // A value that is exactly this connector's generated variable is shown as a stored secret. + var isSecret = McpConnector.IsVariableReference(value.Value, out var variable) && + variable == McpConnector.GetSecretVariableName(_original!.Name, value.Key); + AddValue(target, isSecret ? value with { Value = string.Empty } : value, isSecret, + isSecret && _storedVariables.Contains(variable)); + } + + private void AddValue(ObservableCollection target, McpConnectorValue value, + bool isSecret, bool hasStoredValue) + { + var row = new CopilotConnectorValueViewModel(value.Key, value.Value, isSecret, hasStoredValue, + isSecret ? value.Key : null); + row.RemoveCommand = new RelayCommand(() => + { + target.Remove(row); + UpdateVariables(); + }); + row.PropertyChanged += OnRowChanged; + target.Add(row); + } + + private void OnRowChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(CopilotConnectorValueViewModel.Value) + or nameof(CopilotConnectorValueViewModel.IsSecret)) + UpdateVariables(); + } + + private McpConnector BuildConnector(bool forVariables) + { + var connector = _original?.Clone() ?? new McpConnector(); + connector.Name = Name.Trim(); + connector.Kind = IsRemote ? McpConnectorKind.Remote : McpConnectorKind.Local; + connector.Command = Command.Trim(); + connector.Args = Arguments.Split('\n').Select(x => x.TrimEnd('\r')).Where(x => x.Length > 0).ToList(); + connector.WorkingDirectory = string.IsNullOrWhiteSpace(WorkingDirectory) ? null : WorkingDirectory.Trim(); + connector.Url = Url.Trim(); + connector.UseSse = UseSse; + connector.OAuthClientId = string.IsNullOrWhiteSpace(OAuthClientId) ? null : OAuthClientId.Trim(); + + McpConnectorValue ToValue(CopilotConnectorValueViewModel row) => new(row.Key.Trim(), row.IsSecret + ? forVariables + ? string.Empty + : McpConnector.ToVariableReference(McpConnector.GetSecretVariableName(connector.Name, row.Key.Trim())) + : row.Value); + + connector.Env = Env.Where(x => !string.IsNullOrWhiteSpace(x.Key)).Select(ToValue).ToList(); + connector.Headers = Headers.Where(x => !string.IsNullOrWhiteSpace(x.Key)).Select(ToValue).ToList(); + return connector; + } + + private void UpdateVariables() + { + var names = BuildConnector(true).GetVariables(); + foreach (var removed in Variables.Where(x => !names.Contains(x.Name)).ToList()) Variables.Remove(removed); + for (var i = 0; i < names.Count; i++) + { + if (Variables.Any(x => x.Name == names[i])) continue; + var variable = new CopilotConnectorVariableViewModel(names[i], _storedVariables.Contains(names[i])); + Variables.Insert(Math.Min(i, Variables.Count), variable); + if (!variable.HasStoredValue) _ = ResolveSourceAsync(variable); + } + + OnPropertyChanged(nameof(HasVariables)); + } + + private async Task ResolveSourceAsync(CopilotConnectorVariableViewModel variable) + { + var source = await _service.GetVariableSourceAsync(variable.Name); + await Dispatcher.UIThread.InvokeAsync(() => variable.Source = source); + } + + private string? Validate(McpConnector connector) + { + if (string.IsNullOrWhiteSpace(connector.Name)) return "Enter a name."; + if (!McpConnector.IsValidName(connector.Name)) + return "The name needs a letter or digit and may only contain letters, digits, '.', '-' and '_'."; + if (_otherNames.Contains(McpConnector.GetVariableKey(connector.Name))) + return $"A connector named '{connector.Name}' or with a similar name already exists."; + + if (connector.Kind == McpConnectorKind.Local) + { + if (string.IsNullOrWhiteSpace(connector.Command)) return "Enter the command that starts the server."; + } + else if (!Uri.TryCreate(connector.Url, UriKind.Absolute, out var uri) || + uri.Scheme is not ("http" or "https")) + { + return "Enter the server's http(s) URL."; + } + + var rows = IsRemote ? Headers : Env; + var keys = rows.Where(x => !string.IsNullOrWhiteSpace(x.Key)).Select(x => x.Key.Trim()).ToList(); + if (keys.Distinct(StringComparer.OrdinalIgnoreCase).Count() != keys.Count) + return IsRemote ? "Each header may only be set once." : "Each variable may only be set once."; + + var secretKeys = rows.Where(x => x.IsSecret && !string.IsNullOrWhiteSpace(x.Key)) + .Select(x => McpConnector.GetVariableKey(x.Key)).ToList(); + if (secretKeys.Distinct(StringComparer.Ordinal).Count() != secretKeys.Count) + return "Secret values need names that differ in more than case and punctuation."; + + var missingSecret = rows.FirstOrDefault(x => + x.IsSecret && !string.IsNullOrWhiteSpace(x.Key) && x.Value.Length == 0 && + !(x.HasStoredValue && x.Key.Trim() == x.StoredKey)); + return missingSecret != null ? $"Enter the secret value of '{missingSecret.Key.Trim()}'." : null; + } + + private async Task SaveAsync(FlexibleWindow? window) + { + var connector = BuildConnector(false); + Error = Validate(connector); + if (Error != null) return; + + IsSaving = true; + try + { + // Secret values keyed by the variables they are stored under; null removes a stored value. + var values = new Dictionary(StringComparer.Ordinal); + var renames = new Dictionary(StringComparer.Ordinal); + var rows = IsRemote ? Headers : Env; + var activeSecrets = new HashSet(StringComparer.Ordinal); + + foreach (var row in rows.Where(x => !string.IsNullOrWhiteSpace(x.Key))) + { + if (!row.IsSecret) continue; + var variable = McpConnector.GetSecretVariableName(connector.Name, row.Key.Trim()); + activeSecrets.Add(variable); + if (row.Value.Length > 0) values[variable] = row.Value; + else if (_original != null && row.StoredKey != null) + renames[McpConnector.GetSecretVariableName(_original.Name, row.StoredKey)] = variable; + } + + // Secrets of removed rows, rows that are no longer secret and of the old name. + if (_original != null) + foreach (var variable in _original.Env.Concat(_original.Headers) + .Select(x => McpConnector.GetSecretVariableName(_original.Name, x.Key)) + .Where(x => _storedVariables.Contains(x) && !activeSecrets.Contains(x) && + !renames.ContainsKey(x))) + values.TryAdd(variable, null); + + foreach (var variable in Variables) + if (variable.Value.Length > 0) values[variable.Name] = variable.Value; + else if (variable.ClearRequested) values[variable.Name] = null; + + await _service.SaveAsync(connector, _original?.Name); + if (renames.Count > 0) await _service.RenameVariablesAsync(renames); + await _service.SetVariablesAsync(values); + + if (OAuthClientSecret.Length > 0) + await _service.SetOAuthClientSecretAsync(connector.Name, OAuthClientSecret); + else if (ClearOAuthClientSecret || connector.Kind == McpConnectorKind.Local) + await _service.SetOAuthClientSecretAsync(connector.Name, null); + + Saved = true; + window?.Close(); + } + catch (Exception e) + { + Error = e.Message; + } + finally + { + IsSaving = false; + } + } +} + +/// An environment variable or header of a connector. +public sealed class CopilotConnectorValueViewModel : ObservableObject +{ + private string _key; + private string _value; + private bool _isSecret; + + public CopilotConnectorValueViewModel(string key, string value, bool isSecret, bool hasStoredValue, + string? storedKey) + { + _key = key; + _value = value; + _isSecret = isSecret; + HasStoredValue = hasStoredValue; + StoredKey = storedKey; + } + + public string Key + { + get => _key; + set => SetProperty(ref _key, value); + } + + public string Value + { + get => _value; + set => SetProperty(ref _value, value); + } + + /// Stores the value in the OS credential store instead of the Copilot CLI configuration. + public bool IsSecret + { + get => _isSecret; + set + { + if (!SetProperty(ref _isSecret, value)) return; + OnPropertyChanged(nameof(Watermark)); + OnPropertyChanged(nameof(PasswordChar)); + } + } + + public bool HasStoredValue { get; } + + /// Key the stored secret belongs to when the editor was opened. + public string? StoredKey { get; } + + public char PasswordChar => IsSecret ? '•' : '\0'; + + public string Watermark => IsSecret && HasStoredValue ? "Stored - leave empty to keep" : + IsSecret ? "Secret value" : "Value, may use ${VAR}"; + + public IRelayCommand? RemoveCommand { get; set; } +} + +/// A ${VAR} a connector references, with where its value comes from. +public sealed class CopilotConnectorVariableViewModel : ObservableObject +{ + private McpVariableSource? _source; + private string _value = string.Empty; + private bool _clearRequested; + + public CopilotConnectorVariableViewModel(string name, bool hasStoredValue) + { + Name = name; + HasStoredValue = hasStoredValue; + if (hasStoredValue) _source = McpVariableSource.OneWare; + ClearCommand = new RelayCommand(() => + { + ClearRequested = true; + Value = string.Empty; + }); + } + + public string Name { get; } + + public bool HasStoredValue { get; } + + public McpVariableSource? Source + { + get => _source; + set + { + if (!SetProperty(ref _source, value)) return; + OnPropertyChanged(nameof(SourceText)); + OnPropertyChanged(nameof(IsMissing)); + } + } + + public bool IsMissing => Source == McpVariableSource.Missing && Value.Length == 0; + + public string SourceText => ClearRequested ? "Removed on save" : Source switch + { + McpVariableSource.OneWare => "Stored in OneWare", + McpVariableSource.Environment => "From your environment", + McpVariableSource.Missing => "Not set", + _ => "Checking..." + }; + + public string Watermark => HasStoredValue && !ClearRequested ? "Stored - leave empty to keep" : + Source == McpVariableSource.Environment ? "Optional, overrides the environment" : "Value"; + + public string Value + { + get => _value; + set + { + if (!SetProperty(ref _value, value)) return; + if (value.Length > 0) ClearRequested = false; + OnPropertyChanged(nameof(IsMissing)); + } + } + + public bool ClearRequested + { + get => _clearRequested; + private set + { + if (!SetProperty(ref _clearRequested, value)) return; + OnPropertyChanged(nameof(SourceText)); + OnPropertyChanged(nameof(Watermark)); + OnPropertyChanged(nameof(CanClear)); + } + } + + public bool CanClear => HasStoredValue && !ClearRequested; + + public IRelayCommand ClearCommand { get; } +} diff --git a/src/OneWare.Copilot/ViewModels/CopilotConnectorsSettingViewModel.cs b/src/OneWare.Copilot/ViewModels/CopilotConnectorsSettingViewModel.cs new file mode 100644 index 00000000..87ebc5ab --- /dev/null +++ b/src/OneWare.Copilot/ViewModels/CopilotConnectorsSettingViewModel.cs @@ -0,0 +1,364 @@ +using System.Collections.ObjectModel; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using OneWare.Copilot.Connectors; +using OneWare.Copilot.Views; +using OneWare.Essentials.Enums; +using OneWare.Essentials.Helpers; +using OneWare.Essentials.Services; + +namespace OneWare.Copilot.ViewModels; + +/// Settings page listing the connectors (IDE-wide MCP servers). +public sealed class CopilotConnectorsSettingViewModel : ObservableObject +{ + private readonly CopilotConnectorService _service; + private bool _isLoading; + private bool _loaded; + private string? _message; + private bool _isError; + + public CopilotConnectorsSettingViewModel(CopilotConnectorService service) + { + _service = service; + _service.SignedIn += (_, name) => Dispatcher.UIThread.Post(() => + { + if (Connectors.FirstOrDefault(x => x.Name == name) is { } item) + item.Status = new McpConnectorStatus(McpConnectorState.Connected); + }); + + AddCommand = new AsyncRelayCommand(AddAsync); + ImportCommand = new AsyncRelayCommand(ImportAsync); + RefreshCommand = new AsyncRelayCommand(() => LoadAsync(true)); + } + + public ObservableCollection Connectors { get; } = []; + + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public string? Message + { + get => _message; + private set => SetProperty(ref _message, value); + } + + public bool IsError + { + get => _isError; + private set => SetProperty(ref _isError, value); + } + + public bool IsEmpty => !IsLoading && Connectors.Count == 0; + + public string ConfigFile => System.IO.Path.Combine( + Services.CopilotSessionConfiguration.GetConfigDirectory(), "mcp-config.json"); + + public IAsyncRelayCommand AddCommand { get; } + + public IAsyncRelayCommand ImportCommand { get; } + + public IAsyncRelayCommand RefreshCommand { get; } + + internal CopilotConnectorService Service => _service; + + /// Loads the list the first time the page is shown. + public void EnsureLoaded() + { + if (_loaded) return; + _loaded = true; + _ = LoadAsync(false); + } + + private async Task LoadAsync(bool force) + { + if (IsLoading) return; + IsLoading = true; + OnPropertyChanged(nameof(IsEmpty)); + if (force) ShowMessage(null); + + try + { + var connectors = await _service.ListAsync(); + var stored = await _service.GetStoredVariablesAsync(); + + Connectors.Clear(); + foreach (var connector in connectors) + Connectors.Add(new CopilotConnectorItemViewModel(this, connector)); + + foreach (var item in Connectors.ToList()) await item.UpdateVariablesAsync(stored); + } + catch (Exception e) + { + ContainerLocator.Container.Resolve().LogWarning(e, "Failed to load the connectors."); + ShowMessage(e.Message, true); + } + finally + { + IsLoading = false; + OnPropertyChanged(nameof(IsEmpty)); + } + + // Remote connectors are cheap to check; local ones would run their command, so they are checked on demand. + foreach (var item in Connectors.Where(x => x is { IsRemote: true, Enabled: true }).ToList()) + await item.CheckAsync(); + } + + internal void ShowMessage(string? message, bool isError = false) + { + Message = message; + IsError = isError; + } + + private async Task AddAsync(Control? owner) + { + var editor = await CopilotConnectorEditorViewModel.CreateAsync(_service, null, + Connectors.Select(x => x.Name)); + if (await ShowEditorAsync(editor, owner)) await LoadAsync(true); + } + + internal async Task EditAsync(CopilotConnectorItemViewModel item, Control? owner) + { + var editor = await CopilotConnectorEditorViewModel.CreateAsync(_service, item.Connector, + Connectors.Where(x => x != item).Select(x => x.Name)); + if (await ShowEditorAsync(editor, owner)) await LoadAsync(true); + } + + private static async Task ShowEditorAsync(CopilotConnectorEditorViewModel editor, Control? owner) + { + var window = new CopilotConnectorEditorView { DataContext = editor }; + await ContainerLocator.Container.Resolve() + .ShowDialogAsync(window, owner == null ? null : TopLevel.GetTopLevel(owner) as Window); + return editor.Saved; + } + + internal async Task RemoveAsync(CopilotConnectorItemViewModel item, Control? owner) + { + var result = await ContainerLocator.Container.Resolve().ShowYesNoAsync("Remove Connector", + $"Remove the connector '{item.Name}'? It is removed from the Copilot CLI configuration as well.", + MessageBoxIcon.Warning, owner == null ? null : TopLevel.GetTopLevel(owner) as Window); + if (result != MessageBoxStatus.Yes) return; + + try + { + await _service.RemoveAsync(item.Connector); + Connectors.Remove(item); + OnPropertyChanged(nameof(IsEmpty)); + } + catch (Exception e) + { + ShowMessage($"Failed to remove '{item.Name}': {e.Message}", true); + } + } + + private async Task ImportAsync(Control? owner) + { + if (owner == null || TopLevel.GetTopLevel(owner) is not { } topLevel) return; + + var file = await StorageProviderHelper.SelectFileAsync(topLevel, "Import MCP Servers", null, + new FilePickerFileType("MCP configuration") { Patterns = ["*.json"] }); + if (file == null) return; + + var warnings = new List(); + var imported = McpConnectorImport.Read(file, warnings.Add); + // Names with the same variable key would share stored values. + var existing = Connectors.Select(x => McpConnector.GetVariableKey(x.Name)).ToHashSet(StringComparer.Ordinal); + var added = new List(); + + foreach (var (connector, clientSecret) in imported) + { + if (!existing.Add(McpConnector.GetVariableKey(connector.Name))) + { + warnings.Add($"'{connector.Name}' already exists or clashes with a similar name and was skipped."); + continue; + } + + try + { + await _service.SaveAsync(connector); + if (!string.IsNullOrEmpty(clientSecret)) + await _service.SetOAuthClientSecretAsync(connector.Name, clientSecret); + added.Add(connector.Name); + } + catch (Exception e) + { + warnings.Add($"'{connector.Name}' could not be added: {e.Message}"); + } + } + + await LoadAsync(true); + + var summary = added.Count == 0 + ? "No connectors were imported." + : $"Imported {string.Join(", ", added)}."; + if (warnings.Count > 0) summary += " " + string.Join(" ", warnings); + ShowMessage(summary, warnings.Count > 0 && added.Count == 0); + } +} + +public sealed class CopilotConnectorItemViewModel : ObservableObject +{ + private readonly CopilotConnectorsSettingViewModel _owner; + private bool _enabled; + private bool _isBusy; + private McpConnectorStatus? _status; + private string? _missingVariables; + + public CopilotConnectorItemViewModel(CopilotConnectorsSettingViewModel owner, McpConnector connector) + { + _owner = owner; + Connector = connector; + _enabled = connector.Enabled; + + EditCommand = new AsyncRelayCommand(x => _owner.EditAsync(this, x)); + RemoveCommand = new AsyncRelayCommand(x => _owner.RemoveAsync(this, x)); + CheckCommand = new AsyncRelayCommand(CheckAsync); + SignInCommand = new AsyncRelayCommand(SignInAsync); + } + + public McpConnector Connector { get; } + + public string Name => Connector.Name; + + public string Description => Connector.Describe(); + + public bool IsRemote => Connector.Kind == McpConnectorKind.Remote; + + public string KindText => IsRemote ? "Remote" : "Local"; + + public bool Enabled + { + get => _enabled; + set + { + if (!SetProperty(ref _enabled, value)) return; + Connector.Enabled = value; + Status = null; + _ = ApplyEnabledAsync(value); + } + } + + public bool IsBusy + { + get => _isBusy; + private set => SetProperty(ref _isBusy, value); + } + + public McpConnectorStatus? Status + { + get => _status; + set + { + if (!SetProperty(ref _status, value)) return; + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(StatusMessage)); + OnPropertyChanged(nameof(HasStatus)); + OnPropertyChanged(nameof(IsConnected)); + OnPropertyChanged(nameof(NeedsSignIn)); + OnPropertyChanged(nameof(IsFailed)); + OnPropertyChanged(nameof(CanSignIn)); + } + } + + public bool HasStatus => Status != null && Status.State != McpConnectorState.Unknown; + + public bool IsConnected => Status?.State == McpConnectorState.Connected; + + public bool NeedsSignIn => Status?.State == McpConnectorState.NeedsSignIn; + + public bool IsFailed => Status?.State == McpConnectorState.Failed; + + public bool CanSignIn => IsRemote && Enabled && !IsConnected; + + public string StatusText => Status?.State switch + { + McpConnectorState.Connected => "Connected", + McpConnectorState.NeedsSignIn => "Sign-in required", + McpConnectorState.Failed => "Failed", + _ => string.Empty + }; + + public string? StatusMessage => Status?.Message; + + public string? MissingVariables + { + get => _missingVariables; + private set => SetProperty(ref _missingVariables, value); + } + + public IAsyncRelayCommand EditCommand { get; } + + public IAsyncRelayCommand RemoveCommand { get; } + + public IAsyncRelayCommand CheckCommand { get; } + + public IAsyncRelayCommand SignInCommand { get; } + + internal async Task UpdateVariablesAsync(IReadOnlySet stored) + { + var missing = new List(); + foreach (var variable in Connector.GetVariables()) + if (!stored.Contains(variable) && + await _owner.Service.GetVariableSourceAsync(variable) == McpVariableSource.Missing) + missing.Add(variable); + + MissingVariables = missing.Count == 0 ? null : $"Not set: {string.Join(", ", missing)}"; + } + + internal async Task CheckAsync() + { + if (IsBusy) return; + IsBusy = true; + try + { + Status = await _owner.Service.CheckAsync(Connector); + } + catch (Exception e) + { + Status = new McpConnectorStatus(McpConnectorState.Failed, e.Message); + } + finally + { + IsBusy = false; + } + } + + private async Task SignInAsync() + { + if (IsBusy) return; + IsBusy = true; + try + { + Status = await _owner.Service.SignInAsync(Connector); + } + catch (Exception e) + { + Status = new McpConnectorStatus(McpConnectorState.Failed, e.Message); + } + finally + { + IsBusy = false; + } + } + + private async Task ApplyEnabledAsync(bool enabled) + { + try + { + await _owner.Service.SetEnabledAsync(Name, enabled); + OnPropertyChanged(nameof(CanSignIn)); + if (enabled && IsRemote) await CheckAsync(); + } + catch (Exception e) + { + _owner.ShowMessage($"Failed to {(enabled ? "enable" : "disable")} '{Name}': {e.Message}", true); + } + } +} diff --git a/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml b/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml new file mode 100644 index 00000000..4a3868d2 --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml.cs b/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml.cs index fbd97ea8..34df21c9 100644 --- a/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml.cs +++ b/src/OneWare.Copilot/Views/CopilotChatHeaderView.axaml.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using OneWare.Copilot.Services; namespace OneWare.Copilot.Views; @@ -8,4 +9,10 @@ public CopilotChatHeaderView() { InitializeComponent(); } + + private void OnFlyoutOpened(object? sender, EventArgs e) + { + if (DataContext is CopilotChatServiceBase { RefreshMcpServersCommand: { IsRunning: false } refresh }) + refresh.Execute(null); + } } diff --git a/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml b/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml index 4a3868d2..91b2e495 100644 --- a/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml +++ b/src/OneWare.Copilot/Views/CopilotConnectorEditorView.axaml @@ -5,142 +5,152 @@ xmlns:controls="clr-namespace:OneWare.Essentials.Controls;assembly=OneWare.Essentials" xmlns:viewModels="clr-namespace:OneWare.Copilot.ViewModels" mc:Ignorable="d" d:DesignWidth="560" d:DesignHeight="600" - PrefWidth="560" PrefHeight="640" + PrefWidth="580" PrefHeight="620" Name="CopilotConnectorEditorViewView" Title="{Binding Title}" WindowStartupLocation="CenterOwner" x:Class="OneWare.Copilot.Views.CopilotConnectorEditorView" x:DataType="viewModels:CopilotConnectorEditorViewModel"> - - - - - - - - - - - - - - + + + - - - + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + diff --git a/src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml.cs b/src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml.cs index 3cf6f677..2eed503f 100644 --- a/src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml.cs +++ b/src/OneWare.Copilot/Views/CopilotConnectorsSettingView.axaml.cs @@ -1,12 +1,13 @@ using Avalonia; using Avalonia.Controls; -using Avalonia.VisualTree; using OneWare.Copilot.ViewModels; namespace OneWare.Copilot.Views; public partial class CopilotConnectorsSettingView : UserControl { + private CopilotConnectorsSettingViewModel? _attached; + public CopilotConnectorsSettingView() { InitializeComponent(); @@ -15,12 +16,28 @@ public CopilotConnectorsSettingView() protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); - (DataContext as CopilotConnectorsSettingViewModel)?.EnsureLoaded(); + Attach(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _attached?.Detach(); + _attached = null; } protected override void OnDataContextChanged(EventArgs e) { base.OnDataContextChanged(e); - if (this.IsAttachedToVisualTree()) (DataContext as CopilotConnectorsSettingViewModel)?.EnsureLoaded(); + if (VisualRoot == null) return; + _attached?.Detach(); + _attached = null; + Attach(); + } + + private void Attach() + { + _attached = DataContext as CopilotConnectorsSettingViewModel; + _attached?.Attach(); } } diff --git a/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml b/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml new file mode 100644 index 00000000..147f6c35 --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml @@ -0,0 +1,16 @@ + + + + + diff --git a/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml.cs b/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml.cs new file mode 100644 index 00000000..8a5aca04 --- /dev/null +++ b/src/OneWare.Copilot/Views/CopilotConnectorsWindow.axaml.cs @@ -0,0 +1,11 @@ +using OneWare.Essentials.Controls; + +namespace OneWare.Copilot.Views; + +public partial class CopilotConnectorsWindow : FlexibleWindow +{ + public CopilotConnectorsWindow() + { + InitializeComponent(); + } +} diff --git a/src/OneWare.Essentials/Models/OneWareAiFunction.cs b/src/OneWare.Essentials/Models/OneWareAiFunction.cs index 065766b5..9a7431ca 100644 --- a/src/OneWare.Essentials/Models/OneWareAiFunction.cs +++ b/src/OneWare.Essentials/Models/OneWareAiFunction.cs @@ -38,6 +38,13 @@ public interface IOneWareAiFunction /// function that changes files, the IDE or the system must leave this . /// bool IsReadOnly => false; + + /// + /// Whether the function is always sent to the model. Chats that defer tools behind a tool search (because MCP + /// servers add hundreds of tools) only load the others when the model searches for them, so mark only the few + /// functions almost every request needs. + /// + bool AlwaysLoaded => false; } public sealed class AiFunctionInvocationContext(string id, Action reportProgress) @@ -70,4 +77,7 @@ public sealed class OneWareAiFunction : IOneWareAiFunction /// public bool IsReadOnly { get; init; } + + /// + public bool AlwaysLoaded { get; init; } } diff --git a/src/OneWare.Essentials/Services/IAiFunctionProvider.cs b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs index 2895b5b9..17796f25 100644 --- a/src/OneWare.Essentials/Services/IAiFunctionProvider.cs +++ b/src/OneWare.Essentials/Services/IAiFunctionProvider.cs @@ -101,6 +101,11 @@ void RegisterSkillDirectory(string directory) /// bool? IsFunctionReadOnly(string functionName) => null; + /// + /// Returns whether the named function is registered and marked . + /// + bool IsFunctionAlwaysLoaded(string functionName) => false; + /// /// Returns the delegate for the named function, /// or if the function has no check or is not registered. diff --git a/tests/OneWare.Copilot.UnitTests/ConnectorTests.cs b/tests/OneWare.Copilot.UnitTests/ConnectorTests.cs index 0b5e9537..c95d5cc4 100644 --- a/tests/OneWare.Copilot.UnitTests/ConnectorTests.cs +++ b/tests/OneWare.Copilot.UnitTests/ConnectorTests.cs @@ -1,10 +1,13 @@ -using GitHub.Copilot; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json.Nodes; +using System.Threading.Tasks; +using GitHub.Copilot; +using Microsoft.Extensions.Logging.Abstractions; using OneWare.Copilot.Connectors; +using OneWare.Essentials.Services; using Xunit; namespace OneWare.Copilot.UnitTests; @@ -13,6 +16,8 @@ public sealed class ConnectorTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "OneWare-Connectors-" + Guid.NewGuid()); private readonly List _warnings = []; + private readonly FakeSecretStore _secrets = new(); + private readonly List _hubs = []; public ConnectorTests() { @@ -26,33 +31,44 @@ private IReadOnlyList Import(string json) return McpConnectorImport.Read(file, _warnings.Add); } + private McpConnectorHub CreateHub() + { + var hub = new McpConnectorHub(_secrets, new FakePaths(Path.Combine(_root, "appdata")), NullLogger.Instance); + _hubs.Add(hub); + return hub; + } + + private static JsonObject Json(string json) => JsonNode.Parse(json)!.AsObject(); + [Fact] public void FromConfig_ReadsCliConfigurationsAndKeepsUnknownFields() { - // The CLI omits "type" for stdio servers when it stores them. - var local = McpConnector.FromConfig("mail", JsonNode.Parse(""" - {"command":"npx","args":["-y","ms365"],"env":{"TOKEN":"${MS_TOKEN}"},"cwd":"/work","tools":["read"],"timeout":5000} - """)!.AsObject(), enabled: false); + // The CLI omits "type" for stdio servers. + var local = McpConnector.FromConfig("mail", Json(""" + {"command":"npx","args":["-y","ms365"],"env":{"TOKEN":"${MS_TOKEN}"},"cwd":"/work","tools":["read"], + "timeout":5000,"disabled":true} + """)); Assert.Equal(McpConnectorKind.Local, local.Kind); Assert.False(local.Enabled); Assert.Equal("npx -y ms365", local.Describe()); - Assert.Equal([new McpConnectorValue("TOKEN", "${MS_TOKEN}")], local.Env); + Assert.Equal([new McpConnectorValue("TOKEN", "${MS_TOKEN}", false)], local.Env); - var config = local.ToConfig(); + var config = local.ToCliConfig(); Assert.Equal("stdio", config["type"]!.GetValue()); Assert.Equal("/work", config["cwd"]!.GetValue()); Assert.Equal(5000, config["timeout"]!.GetValue()); Assert.Equal("read", config["tools"]![0]!.GetValue()); + Assert.Null(config["disabled"]); - var remote = McpConnector.FromConfig("asana", JsonNode.Parse(""" + var remote = McpConnector.FromConfig("asana", Json(""" {"type":"sse","url":"https://mcp.asana.com/sse","headers":{"X":"1"},"oauthClientId":"abc","oauthPublicClient":false} - """)!.AsObject(), enabled: true); + """)); Assert.Equal(McpConnectorKind.Remote, remote.Kind); Assert.True(remote.UseSse); Assert.False(remote.OAuthPublicClient); - var remoteConfig = remote.ToConfig(); + var remoteConfig = remote.ToCliConfig(); Assert.Equal("sse", remoteConfig["type"]!.GetValue()); Assert.Equal("abc", remoteConfig["oauthClientId"]!.GetValue()); Assert.Null(remoteConfig["command"]); @@ -60,13 +76,47 @@ public void FromConfig_ReadsCliConfigurationsAndKeepsUnknownFields() } [Fact] - public void Clone_DoesNotAddTheDefaultToolFilter() + public void StoredConfig_LeavesSecretValuesOutAndRoundTrips() { - var connector = McpConnector.FromConfig("x", JsonNode.Parse("""{"url":"https://x.dev/mcp"}""")!.AsObject(), - true); + var connector = new McpConnector + { + Name = "gh", + Kind = McpConnectorKind.Remote, + Url = "https://api.githubcopilot.com/mcp/", + Enabled = false, + Headers = + [ + new McpConnectorValue("Authorization", "Bearer s3cret", true), + new McpConnectorValue("X-Mode", "${MODE}", false) + ] + }; - Assert.False(connector.Clone().OtherFields.ContainsKey("tools")); - Assert.Equal(McpConnectorKind.Remote, connector.Kind); + var stored = connector.ToStoredConfig(); + Assert.DoesNotContain("s3cret", stored.ToJsonString()); + Assert.Equal(connector.Id, stored[McpConnector.IdField]!.GetValue()); + Assert.True(stored[McpConnector.DisabledField]!.GetValue()); + Assert.Null(stored["tools"]); + + var loaded = McpConnector.FromConfig("gh", stored); + Assert.Equal(connector.Id, loaded.Id); + Assert.False(loaded.Enabled); + Assert.Equal(new McpConnectorValue("Authorization", "", true), loaded.Headers.Single(x => x.IsSecret)); + Assert.False(loaded.ToCliConfig(includeSecrets: false).ContainsKey("tools")); + + var secrets = connector.GetSecrets("client-secret"); + Assert.Equal("Bearer s3cret", secrets.Headers["Authorization"]); + Assert.Equal("client-secret", secrets.OAuthClientSecret); + + var resolved = loaded.WithSecrets(secrets); + var config = Assert.IsType(resolved.ToServerConfig()); + Assert.Equal("Bearer s3cret", config.Headers!["Authorization"]); + Assert.Equal("${MODE}", config.Headers["X-Mode"]); + // Secret values never count as ${VAR} references. + Assert.Equal(["MODE"], resolved.GetVariables()); + + resolved.UseSse = true; + Assert.Null(resolved.ToServerConfig()); + Assert.Equal("sse", resolved.ToCliConfig()["type"]!.GetValue()); } [Fact] @@ -77,8 +127,8 @@ public void GetVariables_ListsReferencesOfTheActiveKind() Kind = McpConnectorKind.Local, Command = "${TOOLS}/server", Args = ["--token", "${B_TOKEN}", "${A}"], - Env = [new McpConnectorValue("KEY", "prefix-${B_TOKEN}")], - Headers = [new McpConnectorValue("Authorization", "Bearer ${HEADER_ONLY}")] + Env = [new McpConnectorValue("KEY", "prefix-${B_TOKEN}", false)], + Headers = [new McpConnectorValue("Authorization", "${HEADER_ONLY}", false)] }; Assert.Equal(["A", "B_TOKEN", "TOOLS"], connector.GetVariables()); @@ -88,73 +138,20 @@ public void GetVariables_ListsReferencesOfTheActiveKind() } [Fact] - public void SecretVariableNames_AreSanitizedAndRecognized() + public void Names_AndSecretKeys_AreRecognized() { - Assert.Equal("ONEWARE_MCP_MY_SERVER__X_API_KEY", - McpConnector.GetSecretVariableName("my-server", "x-api-key")); - Assert.True(McpConnector.IsVariableReference("${ONEWARE_MCP_A__B}", out var name)); - Assert.Equal("ONEWARE_MCP_A__B", name); - Assert.False(McpConnector.IsVariableReference("Bearer ${TOKEN}", out _)); Assert.True(McpConnector.IsValidName("ms365.mail_v2-x")); Assert.False(McpConnector.IsValidName("has space")); Assert.False(McpConnector.IsValidName("-._")); - } - - [Fact] - public void SecretVariableNames_DontCollideAcrossConnectors() - { - // Splitting differently between connector name and key gives different variables. - Assert.NotEqual(McpConnector.GetSecretVariableName("a", "B_TOKEN"), - McpConnector.GetSecretVariableName("a_b", "TOKEN")); - Assert.NotEqual(McpConnector.GetSecretVariableName("a", "b__c"), - McpConnector.GetSecretVariableName("a__b", "c")); - Assert.StartsWith(McpConnector.GetSecretVariablePrefix("a"), McpConnector.GetSecretVariableName("a", "x")); - Assert.False(McpConnector.GetSecretVariableName("a_b", "x") - .StartsWith(McpConnector.GetSecretVariablePrefix("a"), StringComparison.Ordinal)); - - // Names that only differ in case or punctuation share a key and are rejected as duplicates. - Assert.Equal(McpConnector.GetVariableKey("my-server"), McpConnector.GetVariableKey("My__Server")); - } - - [Fact] - public void WithVariables_FillsOnlyKnownReferences() - { - var connector = new McpConnector - { - Name = "x", - Kind = McpConnectorKind.Local, - Command = "${BIN}/srv", - Args = ["--token", "${SECRET}", "${OTHER}"], - Env = [new McpConnectorValue("KEY", "Bearer ${SECRET}")] - }; - var values = new Dictionary { ["SECRET"] = "s3", ["BIN"] = "/opt" }; - - var resolved = connector.WithVariables(values); - - Assert.Equal("/opt/srv", resolved.Command); - Assert.Equal(["--token", "s3", "${OTHER}"], resolved.Args); - Assert.Equal("Bearer s3", resolved.Env[0].Value); - Assert.EndsWith("${SECRET}", connector.Env[0].Value); - var config = Assert.IsType(resolved.ToServerConfig()); - Assert.Equal("/opt/srv", config.Command); - Assert.Equal("Bearer s3", config.Env!["KEY"]); - - var remote = new McpConnector - { - Name = "y", - Kind = McpConnectorKind.Remote, - Url = "https://example.com/mcp", - Headers = [new McpConnectorValue("Authorization", "Bearer ${SECRET}")] - }.WithVariables(values); - var remoteConfig = Assert.IsType(remote.ToServerConfig()); - Assert.Equal("Bearer s3", remoteConfig.Headers!["Authorization"]); - remote.UseSse = true; - Assert.Null(remote.ToServerConfig()); + Assert.True(McpConnector.LooksSecret("Authorization")); + Assert.True(McpConnector.LooksSecret("GITHUB_PERSONAL_ACCESS_TOKEN")); + Assert.True(McpConnector.LooksSecret("x-api-key")); + Assert.False(McpConnector.LooksSecret("LOG_LEVEL")); } [Fact] - public void Import_KeepsVariableReferencesForTheCli() + public void Import_KeepsVariableReferencesAndMarksCredentialsSecret() { var imported = Import(""" { @@ -163,7 +160,7 @@ public void Import_KeepsVariableReferencesForTheCli() "github": { "type": "http", "url": "https://api.githubcopilot.com/mcp/", - "headers": { "Authorization": "Bearer ${env:GITHUB_PERSONAL_ACCESS_TOKEN}" } + "headers": { "Authorization": "Bearer abc", "X-Ref": "${GH_TOKEN}" } }, "mail": { "command": "npx", @@ -183,12 +180,14 @@ public void Import_KeepsVariableReferencesForTheCli() Assert.Equal(["github", "mail", "asana"], imported.Select(x => x.Connector.Name)); var github = imported[0].Connector; - Assert.Equal("Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}", github.Headers.Single().Value); + Assert.True(github.Headers.Single(x => x.Key == "Authorization").IsSecret); + // References are resolved by the runtime and stay in mcp.json. + Assert.False(github.Headers.Single(x => x.Key == "X-Ref").IsSecret); var mail = imported[1].Connector; Assert.False(mail.Enabled); Assert.Equal(["-y", "ms365", $"{_root}/data", "${HOME_DIR}"], mail.Args); - Assert.Equal("${MS_TOKEN}", mail.Env.Single().Value); + Assert.False(mail.Env.Single().IsSecret); // A project server ran in its folder. Assert.Equal(_root, mail.WorkingDirectory); Assert.Contains(_warnings, x => x.Contains("HOME_DIR")); @@ -197,7 +196,7 @@ public void Import_KeepsVariableReferencesForTheCli() Assert.Equal(McpConnectorKind.Remote, asana.Connector.Kind); Assert.Equal("client", asana.Connector.OAuthClientId); Assert.Equal("secret", asana.OAuthClientSecret); - Assert.False(asana.Connector.ToConfig().ContainsKey("oauthClientSecret")); + Assert.False(asana.Connector.ToCliConfig().ContainsKey("oauthClientSecret")); } [Fact] @@ -216,7 +215,35 @@ public void Import_AcceptsVsCodeAndClaudeFormatsAndReportsInvalidServers() Assert.Contains(_warnings, x => x.StartsWith("d:")); Assert.Empty(Import("{ not json")); - Assert.Contains(_warnings, x => x.StartsWith("Failed to read")); + Assert.Contains(_warnings, x => x.Contains("not valid JSON")); + } + + [Theory] + [InlineData("""{"command":"npx","args":["-y","pkg"]}""", "fallback")] + [InlineData(""" + "github": {"command":"npx","args":["-y","pkg"]}, + """, "github")] + [InlineData("""{"mcpServers":{"github":{"command":"npx","args":["-y","pkg"]}}}""", "github")] + [InlineData("""{"servers":{"github":{"command":"npx","args":["-y","pkg"]}}}""", "github")] + public void ParseText_AcceptsPastedSnippets(string text, string expectedName) + { + var connector = McpConnectorImport.ParseText(text, _warnings.Add, "fallback").Single().Connector; + + Assert.Equal(expectedName, connector.Name); + Assert.Equal("npx", connector.Command); + Assert.Equal(["-y", "pkg"], connector.Args); + Assert.Null(connector.WorkingDirectory); + } + + [Fact] + public void ParseText_KeepsWorkspaceFolderAndWarns() + { + var connector = McpConnectorImport.ParseText("""{"command":"srv","args":["${workspaceFolder}"]}""", + _warnings.Add).Single().Connector; + + Assert.Equal(["${workspaceFolder}"], connector.Args); + Assert.Contains(_warnings, x => x.Contains("workspaceFolder")); + Assert.Empty(McpConnectorImport.ParseText("[]", _warnings.Add)); } [Fact] @@ -249,15 +276,103 @@ public void RuntimeEnvironment_LayersLoginShellAndProcess() [Fact] public void SignInErrors_ExplainMissingAppRegistration() { - var message = CopilotConnectorService.DescribeSignInError( + var message = McpConnectorHub.DescribeSignInError( "Client registration failed: Authorization server does not support dynamic client registration"); - Assert.Contains(CopilotConnectorService.OAuthRedirectUrl, message); - Assert.Equal("other", CopilotConnectorService.DescribeSignInError("other")); + Assert.Contains(McpConnectorHub.OAuthRedirectUrl, message); + Assert.True(McpConnectorHub.IsDynamicRegistrationError(message)); + Assert.Equal("other", McpConnectorHub.DescribeSignInError("other")); + Assert.False(McpConnectorHub.IsDynamicRegistrationError("other")); + } + + [Fact] + public async Task Hub_SavesSecretsInTheStoreAndRenamesInPlace() + { + var hub = CreateHub(); + Assert.Empty(await hub.GetConnectorsAsync()); + + var connector = new McpConnector + { + Name = "github", + Kind = McpConnectorKind.Local, + Command = "npx", + Env = [new McpConnectorValue("GITHUB_TOKEN", "ghp_123", true)] + }; + await hub.SaveAsync(connector, null); + await hub.SaveAsync(new McpConnector { Name = "zeta", Command = "z" }, null); + + var file = await File.ReadAllTextAsync(hub.ConfigFile); + Assert.DoesNotContain("ghp_123", file); + Assert.Contains("ghp_123", _secrets.Get(McpConnectorHub.SecretService, connector.Id)); + + // A second hub reads the file from disk. + var other = CreateHub(); + var loaded = (await other.GetConnectorsAsync()).First(); + Assert.Equal("github", loaded.Name); + Assert.Equal("ghp_123", (await other.ResolveAsync(loaded)).Env.Single().Value); + + var renamed = (await other.ResolveAsync(loaded)).Clone(); + renamed.Name = "gh"; + await other.SaveAsync(renamed, null); + Assert.Equal(["gh", "zeta"], (await other.GetConnectorsAsync()).Select(x => x.Name)); + // The file keeps its order. + var keys = JsonNode.Parse(await File.ReadAllTextAsync(hub.ConfigFile))!["mcpServers"]!.AsObject() + .Select(x => x.Key); + Assert.Equal(["gh", "zeta"], keys); + + await Assert.ThrowsAsync(() => + other.SaveAsync(new McpConnector { Name = "GH", Command = "x" }, null)); + + var resolved = Assert.Single(await other.ResolveEnabledAsync(), x => x.Name == "gh"); + Assert.Equal("ghp_123", resolved.Config["env"]!["GITHUB_TOKEN"]!.GetValue()); + var fingerprint = resolved.Fingerprint; + + await other.SetEnabledAsync(loaded.Id, false); + Assert.DoesNotContain(await other.ResolveEnabledAsync(), x => x.Name == "gh"); + await other.SetEnabledAsync(loaded.Id, true); + Assert.Equal(fingerprint, (await other.ResolveEnabledAsync()).Single(x => x.Name == "gh").Fingerprint); + + await other.RemoveAsync(loaded.Id); + Assert.Null(_secrets.Get(McpConnectorHub.SecretService, loaded.Id)); + Assert.Equal(["zeta"], (await other.GetConnectorsAsync()).Select(x => x.Name)); + } + + [Fact] + public async Task Hub_AssignsIdsToHandWrittenEntriesAndReportsErrors() + { + var hub = CreateHub(); + Directory.CreateDirectory(Path.GetDirectoryName(hub.ConfigFile)!); + await File.WriteAllTextAsync(hub.ConfigFile, """ + { + "mcpServers": { + "a": { "command": "a" }, + "bad name": { "command": "b" } + } + } + """); + + var connector = Assert.Single(await hub.GetConnectorsAsync()); + Assert.False(string.IsNullOrEmpty(connector.Id)); + Assert.Contains(connector.Id, await File.ReadAllTextAsync(hub.ConfigFile)); + + var added = await hub.AddAsync(McpConnectorImport.ParseText(""" + {"mcpServers":{"A":{"command":"dup"},"new":{"url":"https://x.dev/mcp","headers":{"Authorization":"Bearer t"}}}} + """, _warnings.Add), _warnings.Add); + Assert.Equal(["new"], added); + Assert.Contains(_warnings, x => x.Contains("'A' already exists")); + + var broken = CreateHub(); + await File.WriteAllTextAsync(broken.ConfigFile, "{ broken"); + Assert.Empty(await broken.GetConnectorsAsync()); + Assert.NotNull(broken.LoadError); + await Assert.ThrowsAsync(() => + broken.SaveAsync(new McpConnector { Name = "x", Command = "x" }, null)); + Assert.Equal("{ broken", await File.ReadAllTextAsync(broken.ConfigFile)); } public void Dispose() { + foreach (var hub in _hubs) hub.Dispose(); try { Directory.Delete(_root, true); @@ -266,4 +381,36 @@ public void Dispose() { } } + + private sealed class FakeSecretStore : ISecretStore + { + private readonly Dictionary<(string, string), string> _values = new(); + + public string? Get(string service, string account) => _values.GetValueOrDefault((service, account)); + + public void Set(string service, string account, string secret) => _values[(service, account)] = secret; + + public void Remove(string service, string account) => _values.Remove((service, account)); + } + + private sealed class FakePaths(string root) : IPaths + { + public string AppName => "OneWare"; + public string AppIconPath => string.Empty; + public string AppFolderName => "OneWare"; + public string AppDataDirectory => root; + public string TempDirectory => Path.Combine(root, "temp"); + public string SessionDirectory => Path.Combine(root, "session"); + public string LayoutDirectory => Path.Combine(root, "layout"); + public string SettingsPath => Path.Combine(root, "settings.json"); + public string DocumentsDirectory => root; + public string CrashReportsDirectory => root; + public string ProjectsDirectory => root; + public string PackagesDirectory => root; + public string NativeToolsDirectory => root; + public string OnnxRuntimesDirectory => root; + public string PluginsDirectory => root; + public string ChangelogUrl => string.Empty; + public string UpdateInfoUrl => string.Empty; + } } diff --git a/tests/OneWare.Copilot.UnitTests/CopilotSessionConfigurationTests.cs b/tests/OneWare.Copilot.UnitTests/CopilotSessionConfigurationTests.cs index d1f547a2..3b53ab66 100644 --- a/tests/OneWare.Copilot.UnitTests/CopilotSessionConfigurationTests.cs +++ b/tests/OneWare.Copilot.UnitTests/CopilotSessionConfigurationTests.cs @@ -110,6 +110,7 @@ public void BuildAvailableTools_AllowsMcpWithoutEnablingHostBuiltIns() [InlineData("task", false, false)] [InlineData("skill", false, false)] [InlineData("ask_user", false, false)] + [InlineData("tool_search_tool", false, false)] public void IsExternalTool_SeparatesMcpPermissionsFromTrustedIdeAndIsolatedTools( string toolName, bool isClientTool, bool expected) { @@ -117,7 +118,7 @@ public void IsExternalTool_SeparatesMcpPermissionsFromTrustedIdeAndIsolatedTools } [Fact] - public async Task ApplyToolDeferral_KeepsOnlyCoreToolsLoaded() + public async Task ApplyToolDeferral_KeepsOnlyAlwaysLoadedToolsLoaded() { var core = AIFunctionFactory.Create(() => "content", new AIFunctionFactoryOptions { @@ -126,7 +127,7 @@ public async Task ApplyToolDeferral_KeepsOnlyCoreToolsLoaded() }); var plugin = AIFunctionFactory.Create(() => "x", "pluginTool"); - var tools = CopilotSessionConfiguration.ApplyToolDeferral([core, plugin]); + var tools = CopilotSessionConfiguration.ApplyToolDeferral([core, plugin], name => name == "readFile"); var wrapped = Assert.IsAssignableFrom(tools[0]); Assert.Equal("readFile", wrapped.Name); @@ -138,20 +139,6 @@ public async Task ApplyToolDeferral_KeepsOnlyCoreToolsLoaded() Assert.False(plugin.AdditionalProperties.ContainsKey("defer")); } - [Fact] - public void AlwaysLoadedTools_AreRegisteredBuiltIns() - { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory != null && !File.Exists(Path.Combine(directory.FullName, "OneWare.slnx"))) - directory = directory.Parent; - Assert.NotNull(directory); - - var source = File.ReadAllText(Path.Combine(directory.FullName, "src", "OneWare.Chat", "Services", - "AiBuiltInFunctions.cs")); - foreach (var name in CopilotSessionConfiguration.AlwaysLoadedTools) - Assert.Contains($"Name = \"{name}\"", source); - } - public void Dispose() { Directory.Delete(_root, true); From 6cdeb3c872fbe9f5fd1a574d675121f23f667836 Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Thu, 29 Oct 2026 17:01:09 +0100 Subject: [PATCH 3/3] Add Copilot Fusion tiers and show tool images in chat - Bump GitHub.Copilot.SDK to 1.0.28 and Copilot CLI to 1.0.105 - Offer account-specific auto routing tiers incl. HydraFusion, show Fusion review phases as sub-agent blocks - Add ChatImageEvent and render images returned by tools (MCP, readFile) in the chat, stored per session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/PluginDevelopment.md | 17 ++ docs/changelog.md | 2 + src/OneWare.Chat/Services/ChatImageStore.cs | 65 +++++ .../ChatMessages/ChatMessageImageViewModel.cs | 82 +++++++ .../ViewModels/ChatSessionViewModel.cs | 22 ++ src/OneWare.Chat/ViewModels/ChatTranscript.cs | 29 ++- src/OneWare.Chat/ViewModels/ChatViewModel.cs | 35 ++- .../ChatMessages/ChatMessageImageView.axaml | 46 ++++ .../ChatMessageImageView.axaml.cs | 51 ++++ src/OneWare.Copilot/CopilotModule.cs | 17 +- src/OneWare.Copilot/OneWare.Copilot.csproj | 2 +- .../Services/CopilotAutoTiers.cs | 105 +++++++++ .../Services/CopilotChatService.Fusion.cs | 222 ++++++++++++++++++ .../Services/CopilotChatService.Images.cs | 172 ++++++++++++++ .../Services/CopilotChatService.cs | 119 ++++++++-- .../Services/CopilotSessionConfiguration.cs | 44 ++++ .../Views/CopilotChatExtensionView.axaml | 15 +- .../Models/ChatServiceEvents.cs | 22 ++ .../ChatImageStoreTests.cs | 79 +++++++ .../CopilotAutoTiersTests.cs | 125 ++++++++++ .../CopilotToolImageTests.cs | 104 ++++++++ 21 files changed, 1335 insertions(+), 40 deletions(-) create mode 100644 src/OneWare.Chat/Services/ChatImageStore.cs create mode 100644 src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageImageViewModel.cs create mode 100644 src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml create mode 100644 src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml.cs create mode 100644 src/OneWare.Copilot/Services/CopilotAutoTiers.cs create mode 100644 src/OneWare.Copilot/Services/CopilotChatService.Fusion.cs create mode 100644 src/OneWare.Copilot/Services/CopilotChatService.Images.cs create mode 100644 tests/OneWare.Chat.UnitTests/ChatImageStoreTests.cs create mode 100644 tests/OneWare.Copilot.UnitTests/CopilotAutoTiersTests.cs create mode 100644 tests/OneWare.Copilot.UnitTests/CopilotToolImageTests.cs diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index a9dd29f9..d21cbbba 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -775,6 +775,10 @@ Key points: wrote the message, and `ChatIdleEvent.Model` for turns whose messages carry no model. The chat shows it beneath the message that ended the turn. `ChatSubAgentStartedEvent.Model` does the same for a delegated task, shown in the header of its block. +- Show images a tool returned with `ChatImageEvent` (encoded bytes plus MIME type, optional description and + tool name). The chat shows them below the tool call, saves them under `/Chat/Images//` + and deletes them with their chat. The Copilot chats raise it for image contents and binary results of tool + calls, including images referenced through `session.binary_asset` events. - Call `IChatManagerService.RegisterChatService` during module initialization. - OneWare functions (tools from `IAiFunctionProvider`) report `AiFunctionEvent.SessionId` so the chat shows them in the right conversation. Put an object with a public string `SessionId` property (like the Copilot SDK's `ToolInvocation`) in @@ -1101,6 +1105,19 @@ mode. Ask/Plan and other read-only agents only allow MCP tools that the server m (`readOnlyHint`). Host-capable built-in CLI tools remain unavailable, and customization discovery does not enable repository command hooks. +#### Auto routing tiers and HydraFusion + +The GitHub Copilot chat starts the runtime with the `DYNAMIC_AUTO_TIERS` feature flag +(`COPILOT_CLI_ENABLED_FEATURE_FLAGS`, merged with flags already set) and enables the HydraFusion session +feature flags (`CopilotSessionConfiguration.ApplyFusion`). The tiers of the **Auto** model picker come from +`client.Rpc.Models.ListAsync` (`ModelList.Auto`) and include Fusion tiers when the account offers them +(`CopilotAutoTiers.Build`); if the runtime offers none, the built-in Default, Fast, Efficiency, Balance and +Intelligence tiers are used. BYOK and OneWare Agents chats always use the built-in tiers and never enable Fusion. + +During a Fusion turn, the root phases stay in the normal message flow. Review phases (judge, critic, …) are shown +as sub-agent blocks named *Fusion <phase>*, with their model, verdict, duration and token usage +(`CopilotChatService.Fusion.cs`). + ### Combining agents and skills `OneWareAiAgent.Skills` behaves differently from on-demand loading: the listed skills are diff --git a/docs/changelog.md b/docs/changelog.md index 90becd79..a316ed3f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,6 +6,8 @@ - Values of variables, registers and memory can be edited while the debugger is paused - Extensions can now depend on other extensions, which are installed automatically, and removing an extension also offers to remove the extensions that need it - Added a copy button and a "Copy Message" context menu entry to AI chat messages +- The Copilot auto model routing now offers the tiers available to your account, including HydraFusion, and shows the Fusion review steps in the chat +- Images returned by tools, such as screenshots from MCP servers, are now shown in the AI chat and can be opened or saved - Enabled the Yosys flow for Gowin boards - Fixed a crash when selecting text across a table in Markdown views - Fixed a crash while highlighting text in the editor diff --git a/src/OneWare.Chat/Services/ChatImageStore.cs b/src/OneWare.Chat/Services/ChatImageStore.cs new file mode 100644 index 00000000..d8db638e --- /dev/null +++ b/src/OneWare.Chat/Services/ChatImageStore.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +namespace OneWare.Chat.Services; + +/// +/// Stores images that tools returned in a chat, one folder per session, so they survive restarts without bloating +/// the chat history files and are deleted together with their chat. +/// +internal sealed partial class ChatImageStore(string rootDirectory) +{ + private const string UnassignedFolder = "_unassigned"; + + private static readonly Dictionary Extensions = new(StringComparer.OrdinalIgnoreCase) + { + ["image/png"] = ".png", + ["image/jpeg"] = ".jpg", + ["image/jpg"] = ".jpg", + ["image/gif"] = ".gif", + ["image/webp"] = ".webp", + ["image/bmp"] = ".bmp", + ["image/svg+xml"] = ".svg" + }; + + public string RootDirectory { get; } = rootDirectory; + + public static string GetExtension(string? mimeType) => + mimeType != null && Extensions.TryGetValue(mimeType.Split(';')[0].Trim(), out var extension) + ? extension + : ".img"; + + /// Writes the image (once per content) and returns its path. + public string Store(string? sessionId, byte[] data, string? mimeType) + { + var directory = GetSessionDirectory(sessionId); + Directory.CreateDirectory(directory); + + var hash = Convert.ToHexStringLower(SHA256.HashData(data))[..32]; + var path = Path.Combine(directory, hash + GetExtension(mimeType)); + if (!File.Exists(path)) + { + var temporary = path + ".tmp"; + File.WriteAllBytes(temporary, data); + File.Move(temporary, path, true); + } + + return path; + } + + public void DeleteSession(string? sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) return; + + var directory = GetSessionDirectory(sessionId); + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + + public string GetSessionDirectory(string? sessionId) => + Path.Combine(RootDirectory, string.IsNullOrWhiteSpace(sessionId) + ? UnassignedFolder + : UnsafeCharacters().Replace(sessionId, "_")); + + [GeneratedRegex("[^A-Za-z0-9_.-]|^\\.+$")] + private static partial Regex UnsafeCharacters(); +} diff --git a/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageImageViewModel.cs b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageImageViewModel.cs new file mode 100644 index 00000000..a37fde6b --- /dev/null +++ b/src/OneWare.Chat/ViewModels/ChatMessages/ChatMessageImageViewModel.cs @@ -0,0 +1,82 @@ +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using OneWare.Essentials.Controls; +using OneWare.Essentials.Helpers; +using OneWare.Essentials.Services; + +namespace OneWare.Chat.ViewModels.ChatMessages; + +/// An image a tool returned to the AI, stored next to the chat history. +public class ChatMessageImageViewModel : ObservableObject, IChatMessage, IEstimatedHeightItem +{ + /// Maximum width the preview is decoded to, which keeps large screenshots cheap in memory. + private const int PreviewDecodeWidth = 1024; + + private const double PreviewMaxHeight = 320; + + private bool _loaded; + + public ChatMessageImageViewModel(string filePath, string mimeType) + { + FilePath = filePath; + MimeType = mimeType; + } + + public string FilePath { get; } + + public string MimeType { get; } + + public string? Description { get; init; } + + public string? ToolName { get; init; } + + public string Caption => !string.IsNullOrWhiteSpace(Description) ? Description + : !string.IsNullOrWhiteSpace(ToolName) ? $"Image from {ToolName}" + : "Image"; + + public string FileName => Path.GetFileName(FilePath); + + /// The decoded preview, or null when the file is missing or cannot be decoded. + public Bitmap? Image + { + get + { + if (_loaded) return field; + _loaded = true; + + try + { + if (!File.Exists(FilePath)) return null; + + using var stream = File.OpenRead(FilePath); + var bitmap = new Bitmap(stream); + if (bitmap.PixelSize.Width > PreviewDecodeWidth) + { + var height = Math.Max(1, bitmap.PixelSize.Height * PreviewDecodeWidth / bitmap.PixelSize.Width); + var scaled = bitmap.CreateScaledBitmap(new Avalonia.PixelSize(PreviewDecodeWidth, height)); + bitmap.Dispose(); + bitmap = scaled; + } + + field = bitmap; + } + catch (Exception e) + { + ContainerLocator.Container.Resolve() + ?.Warning($"Showing chat image '{FilePath}' failed", e); + field = null; + } + + return field; + } + } + + public bool HasImage => Image != null; + + public void Open() + { + if (File.Exists(FilePath)) PlatformHelper.OpenHyperLink(FilePath); + } + + public double EstimateHeight(double width) => PreviewMaxHeight + 24; +} diff --git a/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs b/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs index 89a2f2b6..89c7070c 100644 --- a/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs @@ -28,6 +28,12 @@ internal interface IChatSessionHost /// The service switched the chat to another session (new chat, replacement or reset). void OnSessionChanged(ChatSessionViewModel session); + + /// + /// Saves an image a tool returned next to the chat history and returns its path, or null when it could not be + /// saved. Called from the thread that raised the event. + /// + string? StoreImage(ChatSessionViewModel session, ChatImageEvent image); } /// @@ -1284,6 +1290,22 @@ private void OnEventReceived(object? sender, ChatEvent e) }); break; } + case ChatImageEvent x: + { + var path = _host.StoreImage(this, x); + if (path == null) break; + + Dispatcher.UIThread.Post(() => + { + AddMessage(new ChatMessageImageViewModel(path, x.MimeType) + { + Description = x.Description, + ToolName = x.ToolName + }, x.AgentId); + NotifyContentAdded(); + }); + break; + } case ChatIdleEvent x: { FinishTurn(x.Model); diff --git a/src/OneWare.Chat/ViewModels/ChatTranscript.cs b/src/OneWare.Chat/ViewModels/ChatTranscript.cs index 3de5146e..35fc1c77 100644 --- a/src/OneWare.Chat/ViewModels/ChatTranscript.cs +++ b/src/OneWare.Chat/ViewModels/ChatTranscript.cs @@ -35,6 +35,14 @@ internal static class ChatTranscript ToolOutput = tool.ToolOutput, IsSuccessful = tool.IsSuccessful }; + case ChatMessageImageViewModel image: + return new ChatMessageState(ChatMessageKind.Image) + { + ImagePath = image.FilePath, + MimeType = image.MimeType, + Message = image.Description, + ToolName = image.ToolName + }; case ChatMessageErrorViewModel: // Error chat messages are intentionally not serialized. return null; @@ -98,6 +106,19 @@ public static bool TryCreateMessage(ChatMessageState state, out IChatMessage mes IsToolRunning = false }; return true; + case ChatMessageKind.Image: + if (string.IsNullOrWhiteSpace(state.ImagePath)) + { + message = null!; + return false; + } + + message = new ChatMessageImageViewModel(state.ImagePath, state.MimeType ?? "image/png") + { + Description = state.Message, + ToolName = state.ToolName + }; + return true; case ChatMessageKind.Skill: if (string.IsNullOrWhiteSpace(state.SkillName)) { @@ -186,6 +207,11 @@ public ChatMessageState(ChatMessageKind kind) public bool ShowModel { get; set; } public bool IsSuccessful { get; set; } + /// File of an image message, stored by . + public string? ImagePath { get; set; } + + public string? MimeType { get; set; } + /// Messages nested inside a sub-agent block. public List Children { get; set; } = []; } @@ -197,5 +223,6 @@ internal enum ChatMessageKind Reasoning, Tool, Skill, - SubAgent + SubAgent, + Image } diff --git a/src/OneWare.Chat/ViewModels/ChatViewModel.cs b/src/OneWare.Chat/ViewModels/ChatViewModel.cs index 0efa14ed..efbeeaad 100644 --- a/src/OneWare.Chat/ViewModels/ChatViewModel.cs +++ b/src/OneWare.Chat/ViewModels/ChatViewModel.cs @@ -42,6 +42,7 @@ public class ChatViewModel : ExtendedTool, IChatManagerService, IChatSessionHost private readonly ISettingsService _settingsService; private readonly string _statePath; private readonly string _historyRootPath; + private readonly ChatImageStore _imageStore; private readonly Dictionary> _historyByService = new(StringComparer.Ordinal); @@ -79,6 +80,7 @@ public ChatViewModel(IAiFunctionProvider aiFunctionProvider, IMainDockService ma var chatDirectory = Path.Combine(paths.AppDataDirectory, "Chat"); _statePath = Path.Combine(chatDirectory, "ChatState.json"); _historyRootPath = Path.Combine(chatDirectory, "History"); + _imageStore = new ChatImageStore(Path.Combine(chatDirectory, "Images")); AiFileEditService = aiFileEditService; aiFileEditService.ActiveEdits.CollectionChanged += (_, _) => Dispatcher.UIThread.Post(() => @@ -682,6 +684,21 @@ private HashSet GetOpenSessionIds() void IChatSessionHost.SaveNow() => SaveState(); + string? IChatSessionHost.StoreImage(ChatSessionViewModel session, ChatImageEvent image) + { + try + { + return _imageStore.Store(session.CurrentSessionId ?? session.RememberedSessionId, image.Data, + image.MimeType); + } + catch (Exception e) + { + ContainerLocator.Container.Resolve() + ?.Warning("Saving a chat image failed", e); + return null; + } + } + void IChatSessionHost.OnSending(ChatSessionViewModel session) { if (ReferenceEquals(session, DraftSession)) Show(session); @@ -782,6 +799,20 @@ private void ClearServiceHistory(IChatService service) { var serviceName = service.Name; + if (_historyByService.TryGetValue(serviceName, out var removedItems)) + foreach (var item in removedItems) + { + try + { + _imageStore.DeleteSession(item.SessionId); + } + catch (Exception ex) + { + ContainerLocator.Container.Resolve() + ?.Warning($"Deleting the images of chat '{item.Name}' failed", ex); + } + } + _historyByService.Remove(serviceName); var serviceDirectory = GetServiceHistoryDirectory(serviceName); @@ -1008,13 +1039,15 @@ private void PruneSessionHistory(string serviceName) } } - private static bool TryDeleteSessionFile(ChatSessionHistoryItem item) + private bool TryDeleteSessionFile(ChatSessionHistoryItem item) { try { if (!string.IsNullOrWhiteSpace(item.FilePath) && File.Exists(item.FilePath)) File.Delete(item.FilePath); + _imageStore.DeleteSession(item.SessionId); + return true; } catch (Exception e) diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml b/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml new file mode 100644 index 00000000..113e6939 --- /dev/null +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml.cs b/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml.cs new file mode 100644 index 00000000..5ff8411d --- /dev/null +++ b/src/OneWare.Chat/Views/ChatMessages/ChatMessageImageView.axaml.cs @@ -0,0 +1,51 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; +using OneWare.Chat.ViewModels.ChatMessages; +using OneWare.Essentials.Helpers; +using OneWare.Essentials.Services; + +namespace OneWare.Chat.Views.ChatMessages; + +public partial class ChatMessageImageView : UserControl +{ + public ChatMessageImageView() + { + InitializeComponent(); + } + + private ChatMessageImageViewModel? Message => DataContext as ChatMessageImageViewModel; + + private void OnImageTapped(object? sender, TappedEventArgs e) => Message?.Open(); + + private void OnOpenClick(object? sender, RoutedEventArgs e) => Message?.Open(); + + private void OnShowInFolderClick(object? sender, RoutedEventArgs e) + { + if (Message is { } message && File.Exists(message.FilePath)) + PlatformHelper.OpenExplorerPath(message.FilePath); + } + + private async void OnSaveAsClick(object? sender, RoutedEventArgs e) + { + if (Message is not { } message || !File.Exists(message.FilePath)) return; + if (TopLevel.GetTopLevel(this) is not { } topLevel) return; + + var extension = Path.GetExtension(message.FilePath); + try + { + var target = await StorageProviderHelper.SelectSaveFileAsync(topLevel, "Save Image", null, + extension.TrimStart('.'), $"image{extension}", true, + new FilePickerFileType("Image") { Patterns = [$"*{extension}"], MimeTypes = [message.MimeType] }); + if (target == null) return; + + File.Copy(message.FilePath, target, true); + } + catch (Exception ex) + { + ContainerLocator.Container.Resolve() + ?.Error($"Saving the image failed: {ex.Message}", ex); + } + } +} diff --git a/src/OneWare.Copilot/CopilotModule.cs b/src/OneWare.Copilot/CopilotModule.cs index cadfcc4d..a7ce8743 100644 --- a/src/OneWare.Copilot/CopilotModule.cs +++ b/src/OneWare.Copilot/CopilotModule.cs @@ -97,38 +97,38 @@ public class CopilotModule : OneWareModuleBase [ new PackageVersion() { - Version = "1.0.97", + Version = "1.0.105", Targets = [ new PackageTarget() { Target = "win-x64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-win32-x64.zip" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-win32-x64.zip" }, new PackageTarget() { Target = "win-arm64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-win32-arm64.zip" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-win32-arm64.zip" }, new PackageTarget() { Target = "linux-x64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-linux-x64.tar.gz" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-linux-x64.tar.gz" }, new PackageTarget() { Target = "linux-arm64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-linux-arm64.tar.gz" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-linux-arm64.tar.gz" }, new PackageTarget() { Target = "osx-x64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-darwin-x64.tar.gz" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-darwin-x64.tar.gz" }, new PackageTarget() { Target = "osx-arm64", - Url = "https://github.com/github/copilot-cli/releases/download/v1.0.97/copilot-darwin-arm64.tar.gz" + Url = "https://github.com/github/copilot-cli/releases/download/v1.0.105/copilot-darwin-arm64.tar.gz" }, ] } @@ -288,7 +288,8 @@ public override void Initialize(IServiceProvider serviceProvider) "Routing preference for the \"auto\" model, which lets Copilot pick a backend " + "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." + "Intelligence: prefer the most capable models. Copilot can offer further tiers for your " + + "account, e.g. HydraFusion, which combines several models per turn." }); settingsService.Register(CopilotSelectedModelSettingKey, DefaultModelId); diff --git a/src/OneWare.Copilot/OneWare.Copilot.csproj b/src/OneWare.Copilot/OneWare.Copilot.csproj index 412f7d9e..39366358 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/CopilotAutoTiers.cs b/src/OneWare.Copilot/Services/CopilotAutoTiers.cs new file mode 100644 index 00000000..33a8cd66 --- /dev/null +++ b/src/OneWare.Copilot/Services/CopilotAutoTiers.cs @@ -0,0 +1,105 @@ +using GitHub.Copilot.Rpc; + +namespace OneWare.Copilot.Services; + +/// A routing preference of the auto model as offered in the picker. +public sealed class CopilotAutoTierOption(string label, string? id, string? description = null, bool isFusion = false) +{ + public string Label { get; } = label; + + /// Runtime tier id, null to leave the routing to the provider. + public string? Id { get; } = id; + + public string? Description { get; } = description; + + /// True for HydraFusion tiers, which combine several models per turn. + public bool IsFusion { get; } = isFusion; + + public bool HasDescription => !string.IsNullOrWhiteSpace(Description); + + public override string ToString() => Label; +} + +/// +/// Builds the Auto routing preferences. With DYNAMIC_AUTO_TIERS the runtime reports the tiers the account can +/// use (including HydraFusion), otherwise the built-in tiers apply. +/// +internal static class CopilotAutoTiers +{ + internal const string AutoType = "auto"; + internal const string FusionType = "fusion"; + private const string FastId = "fast"; + + public static IReadOnlyList Fallback { get; } = + [ + new(CopilotChatServiceBase.AutoTierDefault, null, "Leave the choice to Copilot."), + new(CopilotChatServiceBase.AutoTierFast, FastId, "Prefer the lowest-latency models."), + new(CopilotChatServiceBase.AutoTierEfficiency, "efficiency", "Prefer cheaper models."), + new(CopilotChatServiceBase.AutoTierBalance, "balance", "Trade off speed and capability."), + new(CopilotChatServiceBase.AutoTierIntelligence, "intelligence", "Prefer the most capable models.") + ]; + + /// + /// The tiers to offer for : enabled, picker-visible auto and fusion + /// tiers in the provider's order. fast is hidden like in the Copilot CLI. Falls back to + /// when the provider reported nothing usable. + /// + public static IReadOnlyList Build(AutoTierMetadata? metadata) + { + if (metadata?.Tiers is not { Count: > 0 } tiers) return Fallback; + + var options = new List(); + var labels = new HashSet(StringComparer.OrdinalIgnoreCase) { CopilotChatServiceBase.AutoTierDefault }; + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var tier in tiers) + { + if (string.IsNullOrWhiteSpace(tier.Id) || !ids.Add(tier.Id)) continue; + if (string.Equals(tier.Id, FastId, StringComparison.OrdinalIgnoreCase)) continue; + + var isFusion = string.Equals(tier.Type, FusionType, StringComparison.OrdinalIgnoreCase); + if (!isFusion && !string.Equals(tier.Type, AutoType, StringComparison.OrdinalIgnoreCase)) continue; + if (tier.Status is { Enabled: false } || tier.Status?.PickerVisible == false) continue; + + var label = GetKnownLabel(tier.Id) ?? + (string.IsNullOrWhiteSpace(tier.DisplayName) ? tier.Id : tier.DisplayName.Trim()); + if (!labels.Add(label)) + { + label = tier.Id; + if (!labels.Add(label)) continue; + } + + options.Add(new CopilotAutoTierOption(label, tier.Id, tier.Description, isFusion)); + } + + if (options.Count == 0) return Fallback; + + var defaultLabel = Describe(options, metadata.DefaultTier); + var defaultDescription = string.IsNullOrWhiteSpace(metadata.DefaultTier) + ? "Leave the choice to Copilot." + : $"Leave the choice to Copilot (currently {defaultLabel})."; + + options.Insert(0, new CopilotAutoTierOption(CopilotChatServiceBase.AutoTierDefault, null, defaultDescription)); + return options; + } + + /// The runtime id of the tier labelled , null for Default or unknown labels. + public static string? ResolveId(IEnumerable options, string? label) + { + if (string.IsNullOrWhiteSpace(label)) return null; + return options.FirstOrDefault(x => string.Equals(x.Label, label, StringComparison.Ordinal))?.Id; + } + + /// The label for the runtime tier ; Default when it is null. + public static string Describe(IEnumerable options, string? id) + { + if (string.IsNullOrWhiteSpace(id)) return CopilotChatServiceBase.AutoTierDefault; + + return options.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))?.Label + ?? GetKnownLabel(id) + ?? id; + } + + private static string? GetKnownLabel(string id) => + Fallback.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))?.Label; +} diff --git a/src/OneWare.Copilot/Services/CopilotChatService.Fusion.cs b/src/OneWare.Copilot/Services/CopilotChatService.Fusion.cs new file mode 100644 index 00000000..7a922be8 --- /dev/null +++ b/src/OneWare.Copilot/Services/CopilotChatService.Fusion.cs @@ -0,0 +1,222 @@ +using GitHub.Copilot; +using Microsoft.Extensions.Logging; +using OneWare.Essentials.Models; +using OneWare.Essentials.Services; + +namespace OneWare.Copilot.Services; + +/// +/// HydraFusion turns (Auto tiers of type fusion) run several models per turn. Phases that work in the root +/// conversation (primary, revision, …) produce the answer and stay in the main flow. Review phases (critic, judge) +/// run in an isolated history and are shown in their own collapsible block, like a sub-agent. +/// +public abstract partial class CopilotChatServiceBase +{ + private const string FusionBlockPrefix = "fusion:"; + + // Running review phases by phase id. + private readonly Dictionary _fusionPhases = new(StringComparer.Ordinal); + + // The router's short summary of a turn's plan, by fusion id. + private readonly Dictionary _fusionHints = new(StringComparer.Ordinal); + + private sealed class FusionReviewPhase(string blockId, string fusionId) + { + public string BlockId { get; } = blockId; + + public string FusionId { get; } = fusionId; + + /// True once the phase's own messages were shown in its block. + public bool HasContent { get; set; } + } + + private static bool IsFusionReviewScope(FusionConversationScope scope) => + string.Equals(scope.Value, FusionConversationScope.Review.Value, StringComparison.OrdinalIgnoreCase); + + /// Handles the HydraFusion lifecycle events; returns false for every other event. + private bool HandleFusionEvent(SessionEvent evt) + { + switch (evt) + { + case SessionFusionResolvedEvent x: + lock (_fusionPhases) + { + if (!string.IsNullOrWhiteSpace(x.Data.FusionId) && !string.IsNullOrWhiteSpace(x.Data.Hint)) + _fusionHints[x.Data.FusionId] = x.Data.Hint; + } + + ContainerLocator.Container.Resolve().LogInformation( + "Copilot Fusion: {Pattern} with {Primary} ({Hint})", x.Data.Pattern.Value, + x.Data.PrimaryModel, x.Data.Hint); + return true; + case SessionFusionRouteFailedEvent x: + ContainerLocator.Container.Resolve().LogWarning( + "Copilot Fusion routing failed ({Reason}): {Error}. Falling back to {Model}.", + x.Data.Reason, x.Data.ErrorMessage, x.Data.FallbackModel); + return true; + case AssistantFusionPhaseStartedEvent x: + if (IsFusionReviewScope(x.Data.ConversationScope)) StartFusionPhase(x.Data); + return true; + case AssistantFusionPhaseCompletedEvent x: + CompleteFusionPhase(x.Data); + return true; + case AssistantFusionPhaseFailedEvent x: + FailFusionPhase(x.Data); + return true; + case SessionFusionCompletedEvent x: + ContainerLocator.Container.Resolve().LogInformation( + "Copilot Fusion finished: {Outcome}, {Phases} phases, {Requests} requests in {Duration:0} ms{Degraded}", + x.Data.Outcome, x.Data.PhaseCount, x.Data.RequestCount, x.Data.DurationMs, + string.IsNullOrWhiteSpace(x.Data.DegradedReason) ? "" : $" (degraded: {x.Data.DegradedReason})"); + CloseFusionPhases(x.Data.FusionId); + return true; + case AssistantFusionPhaseActivityEvent or SessionFusionRouteStartedEvent: + return true; + default: + return false; + } + } + + private void StartFusionPhase(AssistantFusionPhaseStartedData data) + { + if (string.IsNullOrWhiteSpace(data.PhaseId)) return; + + var blockId = FusionBlockPrefix + data.PhaseId; + string? hint; + lock (_fusionPhases) + { + if (_fusionPhases.ContainsKey(data.PhaseId)) return; + _fusionPhases[data.PhaseId] = new FusionReviewPhase(blockId, data.FusionId); + _fusionHints.TryGetValue(data.FusionId ?? "", out hint); + } + + EventReceived?.Invoke(this, new ChatSubAgentStartedEvent(blockId, $"Fusion {DescribeFusionPhase(data.PhaseKind)}") + { + Description = FirstNonEmpty(data.Role, hint), + Model = DescribeModel(data.Model) + }); + } + + private void CompleteFusionPhase(AssistantFusionPhaseCompletedData data) + { + var phase = RemoveFusionPhase(data.PhaseId); + if (phase == null) return; + + // Review output is often only reported here, not as messages of its own. + var text = phase.HasContent ? null : data.Content?.Trim(); + if (!string.IsNullOrWhiteSpace(data.Verdict)) + text = string.IsNullOrWhiteSpace(text) + ? $"**Verdict:** {data.Verdict}" + : $"**Verdict:** {data.Verdict}\n\n{text}"; + + if (!string.IsNullOrWhiteSpace(text)) + EventReceived?.Invoke(this, new ChatMessageEvent(text) { AgentId = phase.BlockId, Model = DescribeModel(data.Model) }); + + var status = data.Status.Value; + EventReceived?.Invoke(this, new ChatSubAgentCompletedEvent(phase.BlockId, + !string.Equals(status, FusionPhaseStatus.Failed.Value, StringComparison.OrdinalIgnoreCase)) + { + Cancelled = string.Equals(status, FusionPhaseStatus.Cancelled.Value, StringComparison.OrdinalIgnoreCase), + Duration = TimeSpan.FromMilliseconds(data.DurationMs), + TotalTokens = data.Usage is { } usage ? usage.InputTokens + usage.OutputTokens : null + }); + } + + private void FailFusionPhase(AssistantFusionPhaseFailedData data) + { + ContainerLocator.Container.Resolve().LogWarning( + "Copilot Fusion phase {Phase} ({Model}) failed ({Reason}): {Error}", data.PhaseKind.Value, data.Model, + data.Reason, data.ErrorMessage); + + var phase = RemoveFusionPhase(data.PhaseId); + if (phase == null) return; + + EventReceived?.Invoke(this, new ChatSubAgentCompletedEvent(phase.BlockId, false) + { + Error = FirstNonEmpty(data.ErrorMessage, data.Reason), + Cancelled = string.Equals(data.Status.Value, FusionPhaseStatus.Cancelled.Value, + StringComparison.OrdinalIgnoreCase), + Duration = TimeSpan.FromMilliseconds(data.DurationMs) + }); + } + + private FusionReviewPhase? RemoveFusionPhase(string? phaseId) + { + if (string.IsNullOrWhiteSpace(phaseId)) return null; + + lock (_fusionPhases) + return _fusionPhases.Remove(phaseId, out var phase) ? phase : null; + } + + /// + /// Closes review blocks that never reported an end, for the turn or, when + /// null, for every turn (session idle). + /// + private void CloseFusionPhases(string? fusionId) + { + List open; + lock (_fusionPhases) + { + open = _fusionPhases.Values + .Where(x => fusionId == null || string.Equals(x.FusionId, fusionId, StringComparison.Ordinal)) + .ToList(); + foreach (var phase in _fusionPhases.Where(x => open.Contains(x.Value)).Select(x => x.Key).ToArray()) + _fusionPhases.Remove(phase); + + if (fusionId == null) _fusionHints.Clear(); + else _fusionHints.Remove(fusionId); + } + + foreach (var phase in open) + EventReceived?.Invoke(this, new ChatSubAgentCompletedEvent(phase.BlockId, true)); + } + + /// + /// The block of the review phase belongs to, or null when it belongs to the main + /// flow. Messages and tools name their phase; streaming deltas don't, so they go to the review phase when exactly + /// one is running. + /// + private string? ResolveFusionAgentId(SessionEvent evt) + { + lock (_fusionPhases) + { + if (_fusionPhases.Count == 0) return null; + + var attribution = evt switch + { + AssistantMessageEvent x => x.Data.Fusion, + ToolExecutionStartEvent x => x.Data.Fusion, + ToolExecutionCompleteEvent x => x.Data.Fusion, + _ => null + }; + + FusionReviewPhase? phase; + if (attribution != null) + { + if (attribution.PhaseId == null || !_fusionPhases.TryGetValue(attribution.PhaseId, out phase)) + return null; + } + else if (evt is AssistantMessageDeltaEvent or AssistantReasoningDeltaEvent or AssistantReasoningEvent + && _fusionPhases.Count == 1) + { + phase = _fusionPhases.Values.First(); + } + else + { + return null; + } + + if (evt is AssistantMessageEvent or AssistantMessageDeltaEvent) phase.HasContent = true; + return phase.BlockId; + } + } + + private static string DescribeFusionPhase(FusionPhaseKind kind) + { + var value = kind.Value; + if (string.IsNullOrWhiteSpace(value)) return "Review"; + + var words = value.Replace('_', ' ').Replace('-', ' ').Trim(); + return char.ToUpperInvariant(words[0]) + words[1..]; + } +} diff --git a/src/OneWare.Copilot/Services/CopilotChatService.Images.cs b/src/OneWare.Copilot/Services/CopilotChatService.Images.cs new file mode 100644 index 00000000..bbadec40 --- /dev/null +++ b/src/OneWare.Copilot/Services/CopilotChatService.Images.cs @@ -0,0 +1,172 @@ +using GitHub.Copilot; +using OneWare.Essentials.Models; + +namespace OneWare.Copilot.Services; + +// Images returned by tools (MCP screenshots, image files the agent read, …) are shown in the chat. +// Large or repeated images are not inlined in the tool result but persisted once as a session.binary_asset +// event and referenced by id, so those assets are cached until the result that references them arrives. +public abstract partial class CopilotChatServiceBase +{ + /// Images larger than this are not shown, which keeps the chat history folder small. + internal const int MaxChatImageBytes = 20 * 1024 * 1024; + + private const int MaxCachedImageAssets = 16; + + private readonly Lock _imageLock = new(); + private readonly Dictionary _imageAssets = new(StringComparer.Ordinal); + private readonly Queue _imageAssetOrder = new(); + private readonly Dictionary _imageToolNames = new(StringComparer.Ordinal); + private readonly List _pendingToolImages = []; + + private sealed record PendingToolImage(string AssetId, string? Description, string ToolCallId, string? AgentId, + string? ToolName); + + internal readonly record struct ToolImage(string Base64, string MimeType, string? Description, string? AssetId); + + private void RememberToolName(string? toolCallId, string? toolName) + { + if (string.IsNullOrEmpty(toolCallId) || string.IsNullOrEmpty(toolName)) return; + lock (_imageLock) _imageToolNames[toolCallId] = toolName; + } + + private void HandleBinaryAsset(SessionBinaryAssetData asset) + { + if (string.IsNullOrEmpty(asset.AssetId) || !IsImage(asset.MimeType) || string.IsNullOrEmpty(asset.Data)) + return; + + List ready; + lock (_imageLock) + { + if (_imageAssets.TryAdd(asset.AssetId, asset)) + { + _imageAssetOrder.Enqueue(asset.AssetId); + while (_imageAssetOrder.Count > MaxCachedImageAssets) + _imageAssets.Remove(_imageAssetOrder.Dequeue()); + } + + ready = _pendingToolImages.Where(x => x.AssetId == asset.AssetId).ToList(); + _pendingToolImages.RemoveAll(x => x.AssetId == asset.AssetId); + } + + foreach (var pending in ready) + EmitToolImage(new ToolImage(asset.Data, asset.MimeType, pending.Description ?? asset.Description, + asset.AssetId), pending.ToolCallId, pending.AgentId, pending.ToolName); + } + + private void HandleToolImages(ToolExecutionCompleteData data, string? agentId) + { + string? toolName; + lock (_imageLock) + _imageToolNames.Remove(data.ToolCallId ?? string.Empty, out toolName); + + if (data.Result == null) return; + + var images = ExtractToolImages(data.Result, ResolveImageAsset, out var missingAssets); + foreach (var image in images) + EmitToolImage(image, data.ToolCallId, agentId, toolName); + + if (missingAssets.Count == 0) return; + + // The asset event may follow the result; show the image when it arrives. + lock (_imageLock) + { + foreach (var reference in missingAssets) + _pendingToolImages.Add(new PendingToolImage(reference.AssetId, reference.Description, + data.ToolCallId ?? string.Empty, agentId, toolName)); + while (_pendingToolImages.Count > MaxCachedImageAssets) + _pendingToolImages.RemoveAt(0); + } + } + + private SessionBinaryAssetData? ResolveImageAsset(string assetId) + { + lock (_imageLock) + return _imageAssets.GetValueOrDefault(assetId); + } + + private void EmitToolImage(ToolImage image, string? toolCallId, string? agentId, string? toolName = null) + { + byte[] bytes; + try + { + bytes = Convert.FromBase64String(image.Base64); + } + catch (FormatException) + { + return; + } + + if (bytes.Length == 0 || bytes.Length > MaxChatImageBytes) return; + + EventReceived?.Invoke(this, new ChatImageEvent(bytes, image.MimeType) + { + AgentId = agentId, + Description = string.IsNullOrWhiteSpace(image.Description) ? null : image.Description, + ToolCallId = toolCallId, + ToolName = toolName + }); + } + + private void ResetImageState() + { + lock (_imageLock) + { + _imageAssets.Clear(); + _imageAssetOrder.Clear(); + _imageToolNames.Clear(); + _pendingToolImages.Clear(); + } + } + + /// + /// The images of a tool result. The structured contents and the model-facing binary results usually carry the + /// same image, so each image is returned once. + /// + internal static List ExtractToolImages(ToolExecutionCompleteResult result, + Func resolveAsset, out List missingAssets) + { + var images = new List(); + var seen = new HashSet(StringComparer.Ordinal); + missingAssets = []; + + void Add(string? data, string? mimeType, string? description, string? assetId = null) + { + if (string.IsNullOrEmpty(data) || !IsImage(mimeType) || !seen.Add(data)) return; + images.Add(new ToolImage(data, mimeType!, description, assetId)); + } + + foreach (var content in result.Contents ?? []) + if (content is ToolExecutionCompleteContentImage image) + Add(image.Data, image.MimeType, null); + + var imageBinaries = 0; + foreach (var binary in result.BinaryResultsForLlm ?? []) + { + if (IsImage(binary.PersistedBinaryImage?.MimeType ?? binary.BinaryAssetReference?.MimeType)) + imageBinaries++; + + if (binary.PersistedBinaryImage is { } inline) + { + Add(inline.Data, inline.MimeType, inline.Description); + } + else if (binary.BinaryAssetReference is { } reference && IsImage(reference.MimeType) && + !string.IsNullOrEmpty(reference.AssetId)) + { + if (resolveAsset(reference.AssetId) is { } asset) + Add(asset.Data, asset.MimeType ?? reference.MimeType, + reference.Description ?? asset.Description, reference.AssetId); + else if (missingAssets.All(x => x.AssetId != reference.AssetId)) + missingAssets.Add(reference); + } + } + + // The contents already carried every image, so the referenced assets are duplicates. + if (images.Count >= imageBinaries) missingAssets.Clear(); + + return images; + } + + internal static bool IsImage(string? mimeType) => + mimeType != null && mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/OneWare.Copilot/Services/CopilotChatService.cs b/src/OneWare.Copilot/Services/CopilotChatService.cs index 0a253ae6..8dd4a1ec 100644 --- a/src/OneWare.Copilot/Services/CopilotChatService.cs +++ b/src/OneWare.Copilot/Services/CopilotChatService.cs @@ -286,6 +286,7 @@ private void MirrorConnectionStateFrom(CopilotChatServiceBase owner) Models.Clear(); Models.AddRange(owner.Models.ToArray()); RefreshFilteredModels(); + SetAutoTiers(owner.AutoTiers.ToArray()); if (SelectedModel == null) ApplyModelSelection(ResolveDefaultSelectedModel(), null, null, null, writeSettings: false, @@ -455,7 +456,7 @@ private void ApplyModelSelection( SelectedContextSize = ContextSizes.FirstOrDefault(x => x.Tier == contextTier) ?? SelectedContextSize; - if (!string.IsNullOrWhiteSpace(autoTier) && AutoTiers.Contains(autoTier)) + if (IsAutoTierOffered(autoTier)) SelectedAutoTier = autoTier; } finally @@ -682,10 +683,70 @@ private void ApplyContextTierToSession() /// /// Routing preferences offered for the auto model. hands the - /// choice back to the provider. + /// choice back to the provider. Replaced by the tiers the runtime reports for the account, which + /// can include HydraFusion. /// - public ObservableCollection AutoTiers { get; } = - [AutoTierDefault, AutoTierFast, AutoTierEfficiency, AutoTierBalance, AutoTierIntelligence]; + public ObservableCollection AutoTiers { get; } = new(CopilotAutoTiers.Fallback); + + private bool IsAutoTierOffered(string? label) => + !string.IsNullOrWhiteSpace(label) && AutoTiers.Any(x => string.Equals(x.Label, label, StringComparison.Ordinal)); + + /// + /// Replaces the offered tiers and keeps the selection if it is still offered, otherwise falls + /// back to the persisted preference or . + /// + private void SetAutoTiers(IReadOnlyList tiers) + { + if (AutoTiers.Select(x => (x.Label, x.Id)).SequenceEqual(tiers.Select(x => (x.Label, x.Id)))) return; + + var selected = SelectedAutoTier; + _suppressAutoTierApply = true; + try + { + AutoTiers.Clear(); + foreach (var tier in tiers) AutoTiers.Add(tier); + + var persisted = settingsService.GetSettingValue(CopilotModule.CopilotAutoTierSettingKey); + SelectedAutoTier = IsAutoTierOffered(selected) ? selected + : IsAutoTierOffered(persisted) ? persisted! + : AutoTierDefault; + } + finally + { + _suppressAutoTierApply = false; + } + } + + /// + /// Asks the runtime which Auto tiers the account can use. They are only reported when the runtime + /// runs with DYNAMIC_AUTO_TIERS; without them the built-in tiers stay. + /// + private async Task> LoadAutoTiersAsync(CopilotClient client) + { + if (_byokConfiguration != null) return CopilotAutoTiers.Fallback; + + try + { + var list = await client.Rpc.Models.ListAsync(null, null); + return CopilotAutoTiers.Build(list.Auto); + } + catch (Exception ex) + { + ContainerLocator.Container.Resolve() + .LogWarning(ex, "Failed to load the Copilot auto routing tiers."); + return CopilotAutoTiers.Fallback; + } + } + + /// Offers the discovered tiers in the global Auto Routing setting as well. + private void PublishAutoTierSetting() + { + if (settingsService.GetSetting(CopilotModule.CopilotAutoTierSettingKey) is not ComboBoxSetting setting) return; + + var labels = AutoTiers.Select(x => (object)x.Label).ToArray(); + if (setting.Options.SequenceEqual(labels)) return; + setting.Options = labels; + } /// /// True while the auto model is selected — only then does a routing preference apply. @@ -730,9 +791,7 @@ private void RefreshAutoTier(ModelInfo model) _suppressAutoTierApply = true; try { - SelectedAutoTier = !string.IsNullOrWhiteSpace(persisted) && AutoTiers.Contains(persisted) - ? persisted - : AutoTierDefault; + SelectedAutoTier = IsAutoTierOffered(persisted) ? persisted : AutoTierDefault; } finally { @@ -740,24 +799,10 @@ private void RefreshAutoTier(ModelInfo model) } } - private AutoTier? ResolveAutoTier() => SelectedAutoTier switch - { - AutoTierFast => AutoTier.Fast, - AutoTierEfficiency => AutoTier.Efficiency, - AutoTierBalance => AutoTier.Balance, - AutoTierIntelligence => AutoTier.Intelligence, - _ => null - }; + private AutoTier? ResolveAutoTier() => + CopilotAutoTiers.ResolveId(AutoTiers, SelectedAutoTier) is { } id ? new AutoTier(id) : null; - 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, - var value => value - }; + private string DescribeAutoTier(AutoTier? tier) => CopilotAutoTiers.Describe(AutoTiers, tier?.Value); private void ApplyAutoTierToSession() { @@ -1615,7 +1660,12 @@ public async Task InitializeAsync() // MCP servers inherit the runtime's environment; the login shell's PATH lets them find npx and co. var connection = RuntimeConnection.ForStdio(cliPath, []); - connection.Environment = await McpConnectorHub.GetRuntimeEnvironmentAsync(); + var environment = await McpConnectorHub.GetRuntimeEnvironmentAsync(); + // Copilot's service reports the Auto tiers the account can use, including HydraFusion. + connection.Environment = _byokConfiguration == null + ? CopilotSessionConfiguration.WithRuntimeFeatureFlag(environment, + CopilotSessionConfiguration.DynamicAutoTiersFeatureFlag, OperatingSystem.IsWindows()) + : environment; var clientOptions = new CopilotClientOptions { @@ -1674,6 +1724,7 @@ public async Task InitializeAsync() await _client.StartAsync(); var models = await _client.ListModelsAsync(); + var autoTiers = await LoadAutoTiersAsync(_client); StatusChanged?.Invoke(this, new StatusEvent(true, $"Copilot started")); Blocker = null; @@ -1681,6 +1732,8 @@ public async Task InitializeAsync() Models.Clear(); Models.AddRange(models.ToArray()); RefreshFilteredModels(); + SetAutoTiers(autoTiers); + Dispatcher.UIThread.Post(PublishAutoTierSetting); ApplyModelSelection(ResolveDefaultSelectedModel(), null, null, null, writeSettings: false, applyToSession: false); @@ -2068,6 +2121,7 @@ private async Task InitializeSessionCoreAsync(string? sessionId) }; CopilotSessionConfiguration.ApplyDiscovery(sessionConfig, workingDirectory, skillDirectories); + if (_byokConfiguration == null) CopilotSessionConfiguration.ApplyFusion(sessionConfig); await ConfigureMcpAsync(sessionConfig, null); _session = await client.CreateSessionAsync(sessionConfig); } @@ -2100,6 +2154,7 @@ private async Task InitializeSessionCoreAsync(string? sessionId) } }; CopilotSessionConfiguration.ApplyDiscovery(resumeConfig, workingDirectory, skillDirectories); + if (_byokConfiguration == null) CopilotSessionConfiguration.ApplyFusion(resumeConfig); await ConfigureMcpAsync(resumeConfig, sessionId); _session = await client.ResumeSessionAsync(sessionId, resumeConfig); } @@ -2526,6 +2581,7 @@ private async Task DisposeSessionAsync() IsRemoteSession = false; RemoteSessionUrl = null; ResetUsageStats(); + ResetImageState(); } private void ResetUsageStats() @@ -2566,6 +2622,9 @@ private void HandleSessionEvent(SessionEvent evt) ? null : ResolveSubAgentId(evt); + if (HandleFusionEvent(evt)) return; + agentId ??= ResolveFusionAgentId(evt); + switch (evt) { case SessionMcpServerStatusChangedEvent x: @@ -2624,9 +2683,15 @@ private void HandleSessionEvent(SessionEvent evt) new ChatReasoningEvent(x.Data.Content, x.Data.ReasoningId) { AgentId = agentId }); break; } + case SessionBinaryAssetEvent x: + { + HandleBinaryAsset(x.Data); + break; + } case ToolExecutionCompleteEvent x: { var toolAgentId = ResolveToolAgentId(x.Data.ParentToolCallId, agentId); + HandleToolImages(x.Data, toolAgentId); if (toolAgentId == null) break; EventReceived?.Invoke(this, new ChatToolExecutionCompleteEvent( @@ -2652,6 +2717,7 @@ private void HandleSessionEvent(SessionEvent evt) case ToolExecutionStartEvent x: { RememberSubAgentInstructions(x.Data); + RememberToolName(x.Data.ToolCallId, x.Data.ToolName); var toolAgentId = ResolveToolAgentId(x.Data.ParentToolCallId, agentId); @@ -2678,6 +2744,7 @@ private void HandleSessionEvent(SessionEvent evt) break; case SessionIdleEvent when agentId == null: DropForegroundSubAgents(); + CloseFusionPhases(null); EventReceived?.Invoke(this, new ChatIdleEvent { Model = _lastTurnModel }); break; case AssistantUsageEvent usage: @@ -3012,7 +3079,7 @@ private bool IsClientTool(string? toolName) private void SyncAutoTierFromSession(AutoTier? tier) { var label = DescribeAutoTier(tier); - if (!AutoTiers.Contains(label) || label == SelectedAutoTier) return; + if (!IsAutoTierOffered(label) || label == SelectedAutoTier) return; Dispatcher.UIThread.Post(() => { diff --git a/src/OneWare.Copilot/Services/CopilotSessionConfiguration.cs b/src/OneWare.Copilot/Services/CopilotSessionConfiguration.cs index ecdb1d70..09e7a7b6 100644 --- a/src/OneWare.Copilot/Services/CopilotSessionConfiguration.cs +++ b/src/OneWare.Copilot/Services/CopilotSessionConfiguration.cs @@ -104,6 +104,50 @@ internal static string GetConfigDirectory() : copilotHome); } + internal const string EnabledFeatureFlagsVariable = "COPILOT_CLI_ENABLED_FEATURE_FLAGS"; + + /// + /// Runtime startup flag that makes the runtime report Auto's tiers (including HydraFusion) from the + /// Copilot service. It also moves the runtime's model catalog to the service's /meta endpoint. + /// + internal const string DynamicAutoTiersFeatureFlag = "DYNAMIC_AUTO_TIERS"; + + /// Session flags HydraFusion tiers need to run, the same set VS Code enables. + internal static readonly string[] FusionFeatureFlags = + ["HYDRAFUSION", "HYDRAFUSION_ROLLOUT", "HYDRAFUSION_INTERRUPTED_COMMIT", "HYDRAFUSION_PLAN_V2"]; + + /// + /// Adds to the runtime's comma separated , + /// keeping flags that are already enabled there. + /// + internal static IReadOnlyDictionary WithRuntimeFeatureFlag( + IReadOnlyDictionary environment, string flag, bool ignoreCase) + { + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var key = environment.Keys.FirstOrDefault(x => string.Equals(x, EnabledFeatureFlagsVariable, comparison)) + ?? EnabledFeatureFlagsVariable; + + var flags = (environment.GetValueOrDefault(key) ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + if (!flags.Contains(flag, StringComparer.OrdinalIgnoreCase)) flags.Add(flag); + + var result = new Dictionary(environment, + ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal) + { + [key] = string.Join(',', flags) + }; + return result; + } + + /// Lets the session run HydraFusion when an Auto tier selects it. + internal static void ApplyFusion(SessionConfigBase config) + { + config.FeatureFlags ??= new Dictionary(); + foreach (var flag in FusionFeatureFlags) + config.FeatureFlags[flag] = true; + } + internal static void ApplyDiscovery(SessionConfigBase config, string workingDirectory, IList skillDirectories) { diff --git a/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml b/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml index 983c388e..172662c1 100644 --- a/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml +++ b/src/OneWare.Copilot/Views/CopilotChatExtensionView.axaml @@ -117,7 +117,8 @@ @@ -130,8 +131,16 @@ - - + + + + + + + diff --git a/src/OneWare.Essentials/Models/ChatServiceEvents.cs b/src/OneWare.Essentials/Models/ChatServiceEvents.cs index e14af84d..e3082886 100644 --- a/src/OneWare.Essentials/Models/ChatServiceEvents.cs +++ b/src/OneWare.Essentials/Models/ChatServiceEvents.cs @@ -151,6 +151,28 @@ public sealed class ChatSkillLoadedEvent(string skillName, string content) public string Content { get; } = content; } +/// +/// An image the AI received from a tool (for example a screenshot of an MCP server or an image file it read). +/// The chat UI shows it below the tool call and keeps it in the chat history. +/// +public sealed class ChatImageEvent(byte[] data, string mimeType) + : ChatEvent() +{ + /// Encoded image bytes (PNG, JPEG, GIF, WebP, …). + public byte[] Data { get; } = data; + + public string MimeType { get; } = mimeType; + + /// Short description of the image, when the tool provides one. + public string? Description { get; init; } + + /// Id of the tool call that returned the image, when known. + public string? ToolCallId { get; init; } + + /// Name of the tool that returned the image, when known. + public string? ToolName { get; init; } +} + public sealed class ChatErrorEvent(string message) : ChatEvent() { diff --git a/tests/OneWare.Chat.UnitTests/ChatImageStoreTests.cs b/tests/OneWare.Chat.UnitTests/ChatImageStoreTests.cs new file mode 100644 index 00000000..5c455973 --- /dev/null +++ b/tests/OneWare.Chat.UnitTests/ChatImageStoreTests.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using OneWare.Chat.Services; +using OneWare.Chat.ViewModels; +using OneWare.Chat.ViewModels.ChatMessages; +using Xunit; + +namespace OneWare.Chat.UnitTests; + +public sealed class ChatImageStoreTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "oneware-chat-images-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_root)) Directory.Delete(_root, true); + } + + [Fact] + public void Store_WritesEachImageOncePerSession() + { + var store = new ChatImageStore(_root); + byte[] data = [1, 2, 3]; + + var first = store.Store("session-1", data, "image/png"); + var second = store.Store("session-1", data, "image/png"); + var other = store.Store("session-2", data, "image/jpeg"); + + Assert.Equal(first, second); + Assert.Equal(".png", Path.GetExtension(first)); + Assert.Equal(".jpg", Path.GetExtension(other)); + Assert.Equal(data, File.ReadAllBytes(first)); + Assert.Single(Directory.GetFiles(store.GetSessionDirectory("session-1"))); + } + + [Fact] + public void DeleteSession_RemovesOnlyThatSession() + { + var store = new ChatImageStore(_root); + var kept = store.Store("keep", [1], "image/png"); + var removed = store.Store("remove", [1], "image/png"); + + store.DeleteSession("remove"); + + Assert.True(File.Exists(kept)); + Assert.False(File.Exists(removed)); + } + + [Theory] + [InlineData("../escape")] + [InlineData("..")] + [InlineData("a/b\\c")] + public void GetSessionDirectory_StaysInsideTheRoot(string sessionId) + { + var directory = new ChatImageStore(_root).GetSessionDirectory(sessionId); + + Assert.Equal(Path.GetFullPath(_root), Path.GetDirectoryName(Path.GetFullPath(directory))); + } + + [Fact] + public void Transcript_KeepsImageMessages() + { + var image = new ChatMessageImageViewModel("/tmp/image.png", "image/png") + { + Description = "Screenshot", + ToolName = "browser_take_screenshot" + }; + + var state = ChatTranscript.BuildMessageState(image); + Assert.NotNull(state); + Assert.True(ChatTranscript.TryCreateMessage(state, out var restored)); + + var restoredImage = Assert.IsType(restored); + Assert.Equal(image.FilePath, restoredImage.FilePath); + Assert.Equal(image.MimeType, restoredImage.MimeType); + Assert.Equal("Screenshot", restoredImage.Caption); + Assert.Equal(image.ToolName, restoredImage.ToolName); + } +} diff --git a/tests/OneWare.Copilot.UnitTests/CopilotAutoTiersTests.cs b/tests/OneWare.Copilot.UnitTests/CopilotAutoTiersTests.cs new file mode 100644 index 00000000..dd850a06 --- /dev/null +++ b/tests/OneWare.Copilot.UnitTests/CopilotAutoTiersTests.cs @@ -0,0 +1,125 @@ +#pragma warning disable GHCP001 // Auto tier metadata is experimental in the SDK. +using System.Collections.Generic; +using System.Linq; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using OneWare.Copilot.Services; +using Xunit; + +namespace OneWare.Copilot.UnitTests; + +public sealed class CopilotAutoTiersTests +{ + private static AutoTierDescriptor Tier(string id, string type = "auto", string? displayName = null, + bool enabled = true, bool? pickerVisible = null) => new() + { + Id = id, + Type = type, + DisplayName = displayName!, + Description = $"{id} description", + Status = new AutoTierStatus { Enabled = enabled, PickerVisible = pickerVisible } + }; + + [Fact] + public void Build_WithoutMetadata_OffersTheBuiltInTiers() + { + Assert.Same(CopilotAutoTiers.Fallback, CopilotAutoTiers.Build(null)); + Assert.Same(CopilotAutoTiers.Fallback, + CopilotAutoTiers.Build(new AutoTierMetadata { Tiers = new List() })); + } + + [Fact] + public void Fallback_UsesTheRuntimeTierIds() + { + var ids = CopilotAutoTiers.Fallback.Select(x => x.Id ?? "").ToArray(); + + Assert.Equal(["", AutoTier.Fast.Value, AutoTier.Efficiency.Value, AutoTier.Balance.Value, + AutoTier.Intelligence.Value], ids); + } + + [Fact] + public void Build_OffersEnabledAutoAndFusionTiersInProviderOrder() + { + var metadata = new AutoTierMetadata + { + DefaultTier = "balance", + Tiers = new List + { + Tier("fast"), + Tier("efficiency", displayName: "Eco"), + Tier("balance"), + Tier("hydrafusion", "fusion", "HydraFusion"), + Tier("hydrafusion-max", "fusion", "HydraFusion Max", enabled: false), + Tier("hidden", pickerVisible: false), + Tier("other", "byok") + } + }; + + var options = CopilotAutoTiers.Build(metadata); + + Assert.Equal(["Default", "Efficiency", "Balance", "HydraFusion"], options.Select(x => x.Label)); + Assert.Equal([false, false, false, true], options.Select(x => x.IsFusion)); + Assert.Contains("Balance", options[0].Description); + } + + [Fact] + public void Build_UsesTheIdWhenDisplayNamesCollide() + { + var options = CopilotAutoTiers.Build(new AutoTierMetadata + { + Tiers = new List { Tier("a", displayName: "Same"), Tier("b", displayName: "Same") } + }); + + Assert.Equal(["Default", "Same", "b"], options.Select(x => x.Label)); + } + + [Fact] + public void ResolveAndDescribe_RoundTripLabelsAndIds() + { + var options = CopilotAutoTiers.Build(new AutoTierMetadata + { + Tiers = new List { Tier("intelligence"), Tier("hydrafusion", "fusion", "HydraFusion") } + }); + + Assert.Equal("hydrafusion", CopilotAutoTiers.ResolveId(options, "HydraFusion")); + Assert.Null(CopilotAutoTiers.ResolveId(options, "Default")); + Assert.Null(CopilotAutoTiers.ResolveId(options, "Unknown")); + + Assert.Equal("HydraFusion", CopilotAutoTiers.Describe(options, "hydrafusion")); + Assert.Equal("Default", CopilotAutoTiers.Describe(options, null)); + Assert.Equal("Fast", CopilotAutoTiers.Describe(options, "fast")); + Assert.Equal("new-tier", CopilotAutoTiers.Describe(options, "new-tier")); + } + + [Fact] + public void WithRuntimeFeatureFlag_KeepsFlagsThatAreAlreadyEnabled() + { + var environment = new Dictionary + { + ["PATH"] = "/usr/bin", + [CopilotSessionConfiguration.EnabledFeatureFlagsVariable] = "SHELL_SPAWN_BACKEND, dynamic_auto_tiers" + }; + + var result = CopilotSessionConfiguration.WithRuntimeFeatureFlag(environment, + CopilotSessionConfiguration.DynamicAutoTiersFeatureFlag, false); + + Assert.Equal("/usr/bin", result["PATH"]); + Assert.Equal("SHELL_SPAWN_BACKEND,dynamic_auto_tiers", + result[CopilotSessionConfiguration.EnabledFeatureFlagsVariable]); + + var added = CopilotSessionConfiguration.WithRuntimeFeatureFlag(new Dictionary(), + CopilotSessionConfiguration.DynamicAutoTiersFeatureFlag, false); + Assert.Equal("DYNAMIC_AUTO_TIERS", added[CopilotSessionConfiguration.EnabledFeatureFlagsVariable]); + } + + [Fact] + public void ApplyFusion_EnablesTheHydraFusionSessionFlags() + { + var config = new SessionConfig { FeatureFlags = new Dictionary { ["OTHER"] = true } }; + + CopilotSessionConfiguration.ApplyFusion(config); + + Assert.True(config.FeatureFlags["OTHER"]); + Assert.All(CopilotSessionConfiguration.FusionFeatureFlags, x => Assert.True(config.FeatureFlags[x])); + } +} diff --git a/tests/OneWare.Copilot.UnitTests/CopilotToolImageTests.cs b/tests/OneWare.Copilot.UnitTests/CopilotToolImageTests.cs new file mode 100644 index 00000000..2d35bab5 --- /dev/null +++ b/tests/OneWare.Copilot.UnitTests/CopilotToolImageTests.cs @@ -0,0 +1,104 @@ +#pragma warning disable GHCP001 // Binary tool results are experimental in the SDK. +using System.Collections.Generic; +using System.Linq; +using GitHub.Copilot; +using OneWare.Copilot.Services; +using Xunit; + +namespace OneWare.Copilot.UnitTests; + +public sealed class CopilotToolImageTests +{ + private const string Png = "iVBORw0KGgo="; + private const string Jpeg = "/9j/4AAQ"; + + private static SessionBinaryAssetData? NoAssets(string _) => null; + + [Fact] + public void ExtractToolImages_ReturnsEachImageOnce() + { + var result = new ToolExecutionCompleteResult + { + Content = string.Empty, + Contents = + [ + new ToolExecutionCompleteContentImage { Data = Png, MimeType = "image/png" }, + new ToolExecutionCompleteContentImage { Data = "AAAA", MimeType = "audio/wav" } + ], + BinaryResultsForLlm = + [ + new PersistedBinaryResult(new PersistedBinaryImage + { + Type = PersistedBinaryImageType.Image, Data = Png, MimeType = "image/png", Description = "Screenshot" }), + new PersistedBinaryResult(new PersistedBinaryImage + { + Type = PersistedBinaryImageType.Image, Data = Jpeg, MimeType = "image/jpeg", Description = "Photo" }) + ] + }; + + var images = CopilotChatServiceBase.ExtractToolImages(result, NoAssets, out var missing); + + Assert.Equal([Png, Jpeg], images.Select(x => x.Base64)); + Assert.Equal("Photo", images[1].Description); + Assert.Empty(missing); + } + + [Fact] + public void ExtractToolImages_ResolvesAssetReferences() + { + var asset = new SessionBinaryAssetData { Type = BinaryAssetType.Image, ByteLength = 8, AssetId = "sha256:1", Data = Png, MimeType = "image/png" }; + var result = new ToolExecutionCompleteResult + { + Content = string.Empty, + BinaryResultsForLlm = + [ + new PersistedBinaryResult(new BinaryAssetReference + { + Type = BinaryAssetReferenceType.Image, ByteLength = 8, AssetId = "sha256:1", MimeType = "image/png", Description = "Generated" }), + new PersistedBinaryResult(new BinaryAssetReference { Type = BinaryAssetReferenceType.Image, ByteLength = 8, AssetId = "sha256:2", MimeType = "image/png" }) + ] + }; + + var images = CopilotChatServiceBase.ExtractToolImages(result, + id => id == asset.AssetId ? asset : null, out var missing); + + var image = Assert.Single(images); + Assert.Equal(Png, image.Base64); + Assert.Equal("Generated", image.Description); + Assert.Equal("sha256:2", Assert.Single(missing).AssetId); + } + + [Fact] + public void ExtractToolImages_DoesNotWaitForAssetsTheContentsAlreadyCarried() + { + var result = new ToolExecutionCompleteResult + { + Content = string.Empty, + Contents = [new ToolExecutionCompleteContentImage { Data = Png, MimeType = "image/png" }], + BinaryResultsForLlm = + [new PersistedBinaryResult(new BinaryAssetReference { Type = BinaryAssetReferenceType.Image, ByteLength = 8, AssetId = "sha256:1", MimeType = "image/png" })] + }; + + var images = CopilotChatServiceBase.ExtractToolImages(result, NoAssets, out var missing); + + Assert.Single(images); + Assert.Empty(missing); + } + + [Fact] + public void ExtractToolImages_IgnoresResultsWithoutImages() + { + var result = new ToolExecutionCompleteResult + { + Content = "text", + BinaryResultsForLlm = + [ + new PersistedBinaryResult(new OmittedBinaryResult { Type = OmittedBinaryType.Image, OmittedReason = OmittedBinaryOmittedReason.TooLarge, MimeType = "image/png", ByteLength = 1 << 30 }), + new PersistedBinaryResult(new PersistedBinaryImage { Type = PersistedBinaryImageType.Resource, Data = "JVBE", MimeType = "application/pdf" }) + ] + }; + + Assert.Empty(CopilotChatServiceBase.ExtractToolImages(result, NoAssets, out var missing)); + Assert.Empty(missing); + } +}