diff --git a/PolyPilot.Console/PolyPilot.csproj b/PolyPilot.Console/PolyPilot.csproj index 15ed5ee5a8..1d08813b89 100644 --- a/PolyPilot.Console/PolyPilot.csproj +++ b/PolyPilot.Console/PolyPilot.csproj @@ -8,8 +8,13 @@ - + + + + + + diff --git a/PolyPilot/Models/AgentSessionInfo.cs b/PolyPilot.Core/Models/AgentSessionInfo.cs similarity index 100% rename from PolyPilot/Models/AgentSessionInfo.cs rename to PolyPilot.Core/Models/AgentSessionInfo.cs diff --git a/PolyPilot/Models/AuditLogEntry.cs b/PolyPilot.Core/Models/AuditLogEntry.cs similarity index 100% rename from PolyPilot/Models/AuditLogEntry.cs rename to PolyPilot.Core/Models/AuditLogEntry.cs diff --git a/PolyPilot/Models/BridgeMessages.cs b/PolyPilot.Core/Models/BridgeMessages.cs similarity index 100% rename from PolyPilot/Models/BridgeMessages.cs rename to PolyPilot.Core/Models/BridgeMessages.cs diff --git a/PolyPilot/Models/ChatCopyPayloads.cs b/PolyPilot.Core/Models/ChatCopyPayloads.cs similarity index 100% rename from PolyPilot/Models/ChatCopyPayloads.cs rename to PolyPilot.Core/Models/ChatCopyPayloads.cs diff --git a/PolyPilot/Models/ChatMessage.cs b/PolyPilot.Core/Models/ChatMessage.cs similarity index 100% rename from PolyPilot/Models/ChatMessage.cs rename to PolyPilot.Core/Models/ChatMessage.cs diff --git a/PolyPilot/Models/CommandHistory.cs b/PolyPilot.Core/Models/CommandHistory.cs similarity index 100% rename from PolyPilot/Models/CommandHistory.cs rename to PolyPilot.Core/Models/CommandHistory.cs diff --git a/PolyPilot/Models/ConnectionSettings.cs b/PolyPilot.Core/Models/ConnectionSettings.cs similarity index 100% rename from PolyPilot/Models/ConnectionSettings.cs rename to PolyPilot.Core/Models/ConnectionSettings.cs diff --git a/PolyPilot/Models/DiffParser.cs b/PolyPilot.Core/Models/DiffParser.cs similarity index 100% rename from PolyPilot/Models/DiffParser.cs rename to PolyPilot.Core/Models/DiffParser.cs diff --git a/PolyPilot/Models/ErrorMessageHelper.cs b/PolyPilot.Core/Models/ErrorMessageHelper.cs similarity index 100% rename from PolyPilot/Models/ErrorMessageHelper.cs rename to PolyPilot.Core/Models/ErrorMessageHelper.cs diff --git a/PolyPilot/Models/ExternalSessionInfo.cs b/PolyPilot.Core/Models/ExternalSessionInfo.cs similarity index 100% rename from PolyPilot/Models/ExternalSessionInfo.cs rename to PolyPilot.Core/Models/ExternalSessionInfo.cs diff --git a/PolyPilot/Models/FiestaModels.cs b/PolyPilot.Core/Models/FiestaModels.cs similarity index 100% rename from PolyPilot/Models/FiestaModels.cs rename to PolyPilot.Core/Models/FiestaModels.cs diff --git a/PolyPilot/Models/LinkHelper.cs b/PolyPilot.Core/Models/LinkHelper.cs similarity index 70% rename from PolyPilot/Models/LinkHelper.cs rename to PolyPilot.Core/Models/LinkHelper.cs index 6888292180..36428486e9 100644 --- a/PolyPilot/Models/LinkHelper.cs +++ b/PolyPilot.Core/Models/LinkHelper.cs @@ -23,15 +23,16 @@ public static void OpenInBackground(string url) { if (!IsValidExternalUrl(url)) return; -#if MACCATALYST - try + if (OperatingSystem.IsMacOS() || OperatingSystem.IsMacCatalyst()) { - var psi = new ProcessStartInfo("open") { UseShellExecute = false }; - psi.ArgumentList.Add("-g"); - psi.ArgumentList.Add(url); - Process.Start(psi)?.Dispose(); + try + { + var psi = new ProcessStartInfo("open") { UseShellExecute = false }; + psi.ArgumentList.Add("-g"); + psi.ArgumentList.Add(url); + Process.Start(psi)?.Dispose(); + } + catch { } } - catch { } -#endif } } diff --git a/PolyPilot/Models/ModelCapabilities.cs b/PolyPilot.Core/Models/ModelCapabilities.cs similarity index 100% rename from PolyPilot/Models/ModelCapabilities.cs rename to PolyPilot.Core/Models/ModelCapabilities.cs diff --git a/PolyPilot/Models/ModelHelper.cs b/PolyPilot.Core/Models/ModelHelper.cs similarity index 100% rename from PolyPilot/Models/ModelHelper.cs rename to PolyPilot.Core/Models/ModelHelper.cs diff --git a/PolyPilot/Models/PendingImage.cs b/PolyPilot.Core/Models/PendingImage.cs similarity index 100% rename from PolyPilot/Models/PendingImage.cs rename to PolyPilot.Core/Models/PendingImage.cs diff --git a/PolyPilot/Models/PlatformHelper.cs b/PolyPilot.Core/Models/PlatformHelper.cs similarity index 82% rename from PolyPilot/Models/PlatformHelper.cs rename to PolyPilot.Core/Models/PlatformHelper.cs index 3a37d15aa8..17f6417922 100644 --- a/PolyPilot/Models/PlatformHelper.cs +++ b/PolyPilot.Core/Models/PlatformHelper.cs @@ -5,34 +5,20 @@ namespace PolyPilot.Models; public static class PlatformHelper { public static bool IsDesktop => -#if MACCATALYST || WINDOWS - true; -#elif IOS || ANDROID - false; -#else - // Linux GTK and other non-mobile platforms are desktop - !OperatingSystem.IsIOS() && !OperatingSystem.IsAndroid(); -#endif + // Runtime detection — OperatingSystem.IsIOS() returns true on Mac Catalyst, + // so we must exclude it explicitly to correctly identify Mac Catalyst as desktop. + OperatingSystem.IsMacCatalyst() || (!OperatingSystem.IsIOS() && !OperatingSystem.IsAndroid()); public static bool IsMobile => -#if IOS || ANDROID - true; -#else - false; -#endif + (OperatingSystem.IsIOS() && !OperatingSystem.IsMacCatalyst()) || OperatingSystem.IsAndroid(); public static string PlatformName => -#if MACCATALYST - "maccatalyst"; -#elif WINDOWS - "windows"; -#elif IOS - "ios"; -#elif ANDROID - "android"; -#else + OperatingSystem.IsMacCatalyst() ? "maccatalyst" : + OperatingSystem.IsMacOS() ? "maccatalyst" : + OperatingSystem.IsWindows() ? "windows" : + OperatingSystem.IsIOS() ? "ios" : + OperatingSystem.IsAndroid() ? "android" : OperatingSystem.IsLinux() ? "linux" : "unknown"; -#endif public static ConnectionMode[] AvailableModes => IsDesktop ? [ConnectionMode.Embedded, ConnectionMode.Persistent, ConnectionMode.Remote] diff --git a/PolyPilot/Models/PluginSettings.cs b/PolyPilot.Core/Models/PluginSettings.cs similarity index 100% rename from PolyPilot/Models/PluginSettings.cs rename to PolyPilot.Core/Models/PluginSettings.cs diff --git a/PolyPilot/Models/PromptLibrary.cs b/PolyPilot.Core/Models/PromptLibrary.cs similarity index 100% rename from PolyPilot/Models/PromptLibrary.cs rename to PolyPilot.Core/Models/PromptLibrary.cs diff --git a/PolyPilot/Models/ReflectionCycle.cs b/PolyPilot.Core/Models/ReflectionCycle.cs similarity index 100% rename from PolyPilot/Models/ReflectionCycle.cs rename to PolyPilot.Core/Models/ReflectionCycle.cs diff --git a/PolyPilot/Models/RenderThrottle.cs b/PolyPilot.Core/Models/RenderThrottle.cs similarity index 100% rename from PolyPilot/Models/RenderThrottle.cs rename to PolyPilot.Core/Models/RenderThrottle.cs diff --git a/PolyPilot/Models/RepositoryInfo.cs b/PolyPilot.Core/Models/RepositoryInfo.cs similarity index 100% rename from PolyPilot/Models/RepositoryInfo.cs rename to PolyPilot.Core/Models/RepositoryInfo.cs diff --git a/PolyPilot/Models/SessionOrganization.cs b/PolyPilot.Core/Models/SessionOrganization.cs similarity index 100% rename from PolyPilot/Models/SessionOrganization.cs rename to PolyPilot.Core/Models/SessionOrganization.cs diff --git a/PolyPilot/Models/SettingDescriptor.cs b/PolyPilot.Core/Models/SettingDescriptor.cs similarity index 100% rename from PolyPilot/Models/SettingDescriptor.cs rename to PolyPilot.Core/Models/SettingDescriptor.cs diff --git a/PolyPilot/Models/SmartPunctuationNormalizer.cs b/PolyPilot.Core/Models/SmartPunctuationNormalizer.cs similarity index 100% rename from PolyPilot/Models/SmartPunctuationNormalizer.cs rename to PolyPilot.Core/Models/SmartPunctuationNormalizer.cs diff --git a/PolyPilot/Models/SquadDiscovery.cs b/PolyPilot.Core/Models/SquadDiscovery.cs similarity index 100% rename from PolyPilot/Models/SquadDiscovery.cs rename to PolyPilot.Core/Models/SquadDiscovery.cs diff --git a/PolyPilot/Models/SquadWriter.cs b/PolyPilot.Core/Models/SquadWriter.cs similarity index 100% rename from PolyPilot/Models/SquadWriter.cs rename to PolyPilot.Core/Models/SquadWriter.cs diff --git a/PolyPilot/Models/SynchronizedMessageQueue.cs b/PolyPilot.Core/Models/SynchronizedMessageQueue.cs similarity index 100% rename from PolyPilot/Models/SynchronizedMessageQueue.cs rename to PolyPilot.Core/Models/SynchronizedMessageQueue.cs diff --git a/PolyPilot/Models/TutorialStep.cs b/PolyPilot.Core/Models/TutorialStep.cs similarity index 100% rename from PolyPilot/Models/TutorialStep.cs rename to PolyPilot.Core/Models/TutorialStep.cs diff --git a/PolyPilot/Models/UsageStatistics.cs b/PolyPilot.Core/Models/UsageStatistics.cs similarity index 100% rename from PolyPilot/Models/UsageStatistics.cs rename to PolyPilot.Core/Models/UsageStatistics.cs diff --git a/PolyPilot.Core/PolyPilot.Core.csproj b/PolyPilot.Core/PolyPilot.Core.csproj new file mode 100644 index 0000000000..399293c9dd --- /dev/null +++ b/PolyPilot.Core/PolyPilot.Core.csproj @@ -0,0 +1,40 @@ + + + + net10.0;net10.0-android + $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + PolyPilot + enable + enable + true + true + + true + + Library + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PolyPilot/Services/AuditLogService.cs b/PolyPilot.Core/Services/AuditLogService.cs similarity index 100% rename from PolyPilot/Services/AuditLogService.cs rename to PolyPilot.Core/Services/AuditLogService.cs diff --git a/PolyPilot/Services/ChatDatabase.cs b/PolyPilot.Core/Services/ChatDatabase.cs similarity index 100% rename from PolyPilot/Services/ChatDatabase.cs rename to PolyPilot.Core/Services/ChatDatabase.cs diff --git a/PolyPilot/Services/CodespaceService.Diagnostics.cs b/PolyPilot.Core/Services/CodespaceService.Diagnostics.cs similarity index 100% rename from PolyPilot/Services/CodespaceService.Diagnostics.cs rename to PolyPilot.Core/Services/CodespaceService.Diagnostics.cs diff --git a/PolyPilot/Services/CodespaceService.Lifecycle.cs b/PolyPilot.Core/Services/CodespaceService.Lifecycle.cs similarity index 100% rename from PolyPilot/Services/CodespaceService.Lifecycle.cs rename to PolyPilot.Core/Services/CodespaceService.Lifecycle.cs diff --git a/PolyPilot/Services/CodespaceService.cs b/PolyPilot.Core/Services/CodespaceService.cs similarity index 100% rename from PolyPilot/Services/CodespaceService.cs rename to PolyPilot.Core/Services/CodespaceService.cs diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot.Core/Services/CopilotService.Bridge.cs similarity index 100% rename from PolyPilot/Services/CopilotService.Bridge.cs rename to PolyPilot.Core/Services/CopilotService.Bridge.cs diff --git a/PolyPilot/Services/CopilotService.Codespace.cs b/PolyPilot.Core/Services/CopilotService.Codespace.cs similarity index 100% rename from PolyPilot/Services/CopilotService.Codespace.cs rename to PolyPilot.Core/Services/CopilotService.Codespace.cs diff --git a/PolyPilot/Services/CopilotService.Continuation.cs b/PolyPilot.Core/Services/CopilotService.Continuation.cs similarity index 100% rename from PolyPilot/Services/CopilotService.Continuation.cs rename to PolyPilot.Core/Services/CopilotService.Continuation.cs diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot.Core/Services/CopilotService.Events.cs similarity index 100% rename from PolyPilot/Services/CopilotService.Events.cs rename to PolyPilot.Core/Services/CopilotService.Events.cs diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot.Core/Services/CopilotService.Organization.cs similarity index 100% rename from PolyPilot/Services/CopilotService.Organization.cs rename to PolyPilot.Core/Services/CopilotService.Organization.cs diff --git a/PolyPilot/Services/CopilotService.Persistence.cs b/PolyPilot.Core/Services/CopilotService.Persistence.cs similarity index 100% rename from PolyPilot/Services/CopilotService.Persistence.cs rename to PolyPilot.Core/Services/CopilotService.Persistence.cs diff --git a/PolyPilot/Services/CopilotService.Providers.cs b/PolyPilot.Core/Services/CopilotService.Providers.cs similarity index 100% rename from PolyPilot/Services/CopilotService.Providers.cs rename to PolyPilot.Core/Services/CopilotService.Providers.cs diff --git a/PolyPilot/Services/CopilotService.Utilities.cs b/PolyPilot.Core/Services/CopilotService.Utilities.cs similarity index 100% rename from PolyPilot/Services/CopilotService.Utilities.cs rename to PolyPilot.Core/Services/CopilotService.Utilities.cs diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot.Core/Services/CopilotService.cs similarity index 99% rename from PolyPilot/Services/CopilotService.cs rename to PolyPilot.Core/Services/CopilotService.cs index 2e5f29c72c..15729b50f6 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot.Core/Services/CopilotService.cs @@ -974,16 +974,17 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) return; } -#if ANDROID - // Android can't run Copilot CLI locally — must connect to remote server - settings.Mode = ConnectionMode.Persistent; - CurrentMode = ConnectionMode.Persistent; - if (settings.Host == "localhost" || settings.Host == "127.0.0.1") + if (OperatingSystem.IsAndroid()) { - Debug("Android detected with localhost — update Host in settings to your Mac's IP"); + // Android can't run Copilot CLI locally — must connect to remote server + settings.Mode = ConnectionMode.Persistent; + CurrentMode = ConnectionMode.Persistent; + if (settings.Host == "localhost" || settings.Host == "127.0.0.1") + { + Debug("Android detected with localhost — update Host in settings to your Mac's IP"); + } + Debug($"Android: connecting to remote server at {settings.CliUrl}"); } - Debug($"Android: connecting to remote server at {settings.CliUrl}"); -#endif // In Persistent mode, auto-start the server if not already running if (settings.Mode == ConnectionMode.Persistent) { @@ -4864,11 +4865,13 @@ public async ValueTask DisposeAsync() private void StartExternalSessionScannerIfNeeded() { -#if ANDROID || IOS - // No local filesystem access on mobile - return; -#else - // UI-thread only -- callers are InitializeAsync, InitializeDemo, and ReconnectAsync + if (OperatingSystem.IsAndroid() || (OperatingSystem.IsIOS() && !OperatingSystem.IsMacCatalyst())) + { + // No local filesystem access on mobile + return; + } + + // UI-thread only -- callers are InitializeAsync, InitializeDemo, and ReconnectAsync if (_externalSessionScanner != null) return; // already running // CWD-based exclusion: sessions whose CWD is inside ~/.polypilot/ are typically PolyPilot's own @@ -4890,7 +4893,6 @@ private void StartExternalSessionScannerIfNeeded() _externalSessionScanner.OnChanged += () => NotifyStateChangedCoalesced(); _externalSessionScanner.Start(); Debug("External session scanner started"); -#endif } private void StopExternalSessionScanner() diff --git a/PolyPilot/Services/DemoService.cs b/PolyPilot.Core/Services/DemoService.cs similarity index 100% rename from PolyPilot/Services/DemoService.cs rename to PolyPilot.Core/Services/DemoService.cs diff --git a/PolyPilot/Services/DevTunnelService.cs b/PolyPilot.Core/Services/DevTunnelService.cs similarity index 100% rename from PolyPilot/Services/DevTunnelService.cs rename to PolyPilot.Core/Services/DevTunnelService.cs diff --git a/PolyPilot/Services/EfficiencyAnalysisService.cs b/PolyPilot.Core/Services/EfficiencyAnalysisService.cs similarity index 100% rename from PolyPilot/Services/EfficiencyAnalysisService.cs rename to PolyPilot.Core/Services/EfficiencyAnalysisService.cs diff --git a/PolyPilot/Services/ExternalSessionScanner.cs b/PolyPilot.Core/Services/ExternalSessionScanner.cs similarity index 100% rename from PolyPilot/Services/ExternalSessionScanner.cs rename to PolyPilot.Core/Services/ExternalSessionScanner.cs diff --git a/PolyPilot/Services/FiestaService.cs b/PolyPilot.Core/Services/FiestaService.cs similarity index 100% rename from PolyPilot/Services/FiestaService.cs rename to PolyPilot.Core/Services/FiestaService.cs diff --git a/PolyPilot/Services/GitAutoUpdateService.cs b/PolyPilot.Core/Services/GitAutoUpdateService.cs similarity index 100% rename from PolyPilot/Services/GitAutoUpdateService.cs rename to PolyPilot.Core/Services/GitAutoUpdateService.cs diff --git a/PolyPilot/Services/GitHubReferenceLinker.cs b/PolyPilot.Core/Services/GitHubReferenceLinker.cs similarity index 100% rename from PolyPilot/Services/GitHubReferenceLinker.cs rename to PolyPilot.Core/Services/GitHubReferenceLinker.cs diff --git a/PolyPilot/Services/HolidayThemeHelper.cs b/PolyPilot.Core/Services/HolidayThemeHelper.cs similarity index 100% rename from PolyPilot/Services/HolidayThemeHelper.cs rename to PolyPilot.Core/Services/HolidayThemeHelper.cs diff --git a/PolyPilot/Services/IChatDatabase.cs b/PolyPilot.Core/Services/IChatDatabase.cs similarity index 100% rename from PolyPilot/Services/IChatDatabase.cs rename to PolyPilot.Core/Services/IChatDatabase.cs diff --git a/PolyPilot/Services/IDemoService.cs b/PolyPilot.Core/Services/IDemoService.cs similarity index 100% rename from PolyPilot/Services/IDemoService.cs rename to PolyPilot.Core/Services/IDemoService.cs diff --git a/PolyPilot/Services/INotificationManagerService.cs b/PolyPilot.Core/Services/INotificationManagerService.cs similarity index 100% rename from PolyPilot/Services/INotificationManagerService.cs rename to PolyPilot.Core/Services/INotificationManagerService.cs diff --git a/PolyPilot/Services/IServerManager.cs b/PolyPilot.Core/Services/IServerManager.cs similarity index 100% rename from PolyPilot/Services/IServerManager.cs rename to PolyPilot.Core/Services/IServerManager.cs diff --git a/PolyPilot/Services/IWsBridgeClient.cs b/PolyPilot.Core/Services/IWsBridgeClient.cs similarity index 100% rename from PolyPilot/Services/IWsBridgeClient.cs rename to PolyPilot.Core/Services/IWsBridgeClient.cs diff --git a/PolyPilot/Services/MarkdownRenderer.cs b/PolyPilot.Core/Services/MarkdownRenderer.cs similarity index 100% rename from PolyPilot/Services/MarkdownRenderer.cs rename to PolyPilot.Core/Services/MarkdownRenderer.cs diff --git a/PolyPilot/Services/NotificationMessageBuilder.cs b/PolyPilot.Core/Services/NotificationMessageBuilder.cs similarity index 100% rename from PolyPilot/Services/NotificationMessageBuilder.cs rename to PolyPilot.Core/Services/NotificationMessageBuilder.cs diff --git a/PolyPilot/Services/PluginFileLogger.cs b/PolyPilot.Core/Services/PluginFileLogger.cs similarity index 100% rename from PolyPilot/Services/PluginFileLogger.cs rename to PolyPilot.Core/Services/PluginFileLogger.cs diff --git a/PolyPilot/Services/PluginLoader.cs b/PolyPilot.Core/Services/PluginLoader.cs similarity index 100% rename from PolyPilot/Services/PluginLoader.cs rename to PolyPilot.Core/Services/PluginLoader.cs diff --git a/PolyPilot/Services/PrLinkService.cs b/PolyPilot.Core/Services/PrLinkService.cs similarity index 100% rename from PolyPilot/Services/PrLinkService.cs rename to PolyPilot.Core/Services/PrLinkService.cs diff --git a/PolyPilot/Services/ProcessHelper.cs b/PolyPilot.Core/Services/ProcessHelper.cs similarity index 100% rename from PolyPilot/Services/ProcessHelper.cs rename to PolyPilot.Core/Services/ProcessHelper.cs diff --git a/PolyPilot/Services/PromptLibraryService.cs b/PolyPilot.Core/Services/PromptLibraryService.cs similarity index 100% rename from PolyPilot/Services/PromptLibraryService.cs rename to PolyPilot.Core/Services/PromptLibraryService.cs diff --git a/PolyPilot/Services/ProviderHostContext.cs b/PolyPilot.Core/Services/ProviderHostContext.cs similarity index 100% rename from PolyPilot/Services/ProviderHostContext.cs rename to PolyPilot.Core/Services/ProviderHostContext.cs diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot.Core/Services/RepoManager.cs similarity index 100% rename from PolyPilot/Services/RepoManager.cs rename to PolyPilot.Core/Services/RepoManager.cs diff --git a/PolyPilot/Services/ServerManager.cs b/PolyPilot.Core/Services/ServerManager.cs similarity index 100% rename from PolyPilot/Services/ServerManager.cs rename to PolyPilot.Core/Services/ServerManager.cs diff --git a/PolyPilot/Services/SessionMetricsExtractor.cs b/PolyPilot.Core/Services/SessionMetricsExtractor.cs similarity index 100% rename from PolyPilot/Services/SessionMetricsExtractor.cs rename to PolyPilot.Core/Services/SessionMetricsExtractor.cs diff --git a/PolyPilot/Services/SettingsRegistry.cs b/PolyPilot.Core/Services/SettingsRegistry.cs similarity index 100% rename from PolyPilot/Services/SettingsRegistry.cs rename to PolyPilot.Core/Services/SettingsRegistry.cs diff --git a/PolyPilot/Services/ShowImageTool.cs b/PolyPilot.Core/Services/ShowImageTool.cs similarity index 100% rename from PolyPilot/Services/ShowImageTool.cs rename to PolyPilot.Core/Services/ShowImageTool.cs diff --git a/PolyPilot/Services/TailscaleService.cs b/PolyPilot.Core/Services/TailscaleService.cs similarity index 100% rename from PolyPilot/Services/TailscaleService.cs rename to PolyPilot.Core/Services/TailscaleService.cs diff --git a/PolyPilot/Services/UsageStatsService.cs b/PolyPilot.Core/Services/UsageStatsService.cs similarity index 95% rename from PolyPilot/Services/UsageStatsService.cs rename to PolyPilot.Core/Services/UsageStatsService.cs index fc6ebfc3cd..3078617feb 100644 --- a/PolyPilot/Services/UsageStatsService.cs +++ b/PolyPilot.Core/Services/UsageStatsService.cs @@ -215,12 +215,13 @@ private void SaveStats() PreallocationSize = json.Length }; -#if !ANDROID && !IOS && !MACCATALYST - if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + if (!OperatingSystem.IsAndroid() && !OperatingSystem.IsIOS() && !OperatingSystem.IsMacCatalyst()) { - options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } } -#endif using var stream = new FileStream(StatsPath, options); using var writer = new StreamWriter(stream); diff --git a/PolyPilot/Services/WindowFocusHelper.cs b/PolyPilot.Core/Services/WindowFocusHelper.cs similarity index 100% rename from PolyPilot/Services/WindowFocusHelper.cs rename to PolyPilot.Core/Services/WindowFocusHelper.cs diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot.Core/Services/WsBridgeClient.cs similarity index 100% rename from PolyPilot/Services/WsBridgeClient.cs rename to PolyPilot.Core/Services/WsBridgeClient.cs diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot.Core/Services/WsBridgeServer.cs similarity index 100% rename from PolyPilot/Services/WsBridgeServer.cs rename to PolyPilot.Core/Services/WsBridgeServer.cs diff --git a/PolyPilot.Gtk/PolyPilot.Gtk.csproj b/PolyPilot.Gtk/PolyPilot.Gtk.csproj index e5d8070367..68ac50ef74 100644 --- a/PolyPilot.Gtk/PolyPilot.Gtk.csproj +++ b/PolyPilot.Gtk/PolyPilot.Gtk.csproj @@ -47,6 +47,7 @@ + @@ -55,15 +56,9 @@ - - - - - - diff --git a/PolyPilot.Provider.Abstractions/PolyPilot.Provider.Abstractions.csproj b/PolyPilot.Provider.Abstractions/PolyPilot.Provider.Abstractions.csproj index c12ac0d76e..64ed643004 100644 --- a/PolyPilot.Provider.Abstractions/PolyPilot.Provider.Abstractions.csproj +++ b/PolyPilot.Provider.Abstractions/PolyPilot.Provider.Abstractions.csproj @@ -11,6 +11,6 @@ - + diff --git a/PolyPilot.Tests/ChatExperienceSafetyTests.cs b/PolyPilot.Tests/ChatExperienceSafetyTests.cs index b4f84fab07..4e3e18c07e 100644 --- a/PolyPilot.Tests/ChatExperienceSafetyTests.cs +++ b/PolyPilot.Tests/ChatExperienceSafetyTests.cs @@ -819,7 +819,7 @@ public async Task FlushCurrentResponse_EmptyContent_NoOp() public void WatchdogCallback_HasGenerationGuard() { var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); // Find the watchdog callback inside InvokeOnUI var watchdogIdx = source.IndexOf("watchdogGeneration != currentGen", StringComparison.Ordinal); @@ -835,7 +835,7 @@ public void WatchdogCallback_HasGenerationGuard() public void CompleteResponse_Source_ClearsSendingFlag() { var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); // Find CompleteResponse method var crIdx = source.IndexOf("private void CompleteResponse(", StringComparison.Ordinal); @@ -854,7 +854,7 @@ public void CompleteResponse_Source_ClearsSendingFlag() public void ReconnectPath_IncludesMcpServersAndSkills() { var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); // After extraction to BuildFreshSessionConfig, verify the reconnect path calls the helper var sessionNotFoundIdx = source.IndexOf("resumeEx.Message.Contains(\"Session not found\"", StringComparison.Ordinal); diff --git a/PolyPilot.Tests/ConnectionRecoveryTests.cs b/PolyPilot.Tests/ConnectionRecoveryTests.cs index 29eb5a1ab2..46347761d5 100644 --- a/PolyPilot.Tests/ConnectionRecoveryTests.cs +++ b/PolyPilot.Tests/ConnectionRecoveryTests.cs @@ -233,7 +233,7 @@ public void SendPromptAsync_ReconnectPath_RefreshesLocalClientAfterRecreation() // SendPromptAsync, the local `client` variable is refreshed after `_client` // is recreated. Without this, ResumeSessionAsync/CreateSessionAsync operate // on the old disposed CopilotClient, throwing "Client not connected". - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); // Find the reconnect block where _client is recreated var recreateIndex = source.IndexOf("_client = CreateClient(connSettings);"); @@ -255,7 +255,7 @@ public void SendPromptAsync_ReconnectPath_UsesRefreshedClientForCreateSession() { // STRUCTURAL REGRESSION GUARD: The stale reference also affects the // "Session not found" fallback where client.CreateSessionAsync is called. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); var recreateIndex = source.IndexOf("_client = CreateClient(connSettings);"); var refreshIndex = source.IndexOf("client = _client", recreateIndex); @@ -277,7 +277,7 @@ public void SendPromptAsync_FreshSessionConfig_IncludesMcpServers() // STRUCTURAL REGRESSION GUARD: The "Session not found" fallback must assign // McpServers in the freshConfig so MCP tools survive reconnection. // After extraction to BuildFreshSessionConfig helper, verify the helper contains it. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); // Verify the reconnect path calls the helper var sessionNotFoundIdx = source.IndexOf("resumeEx.Message.Contains(\"Session not found\"", StringComparison.Ordinal); @@ -297,7 +297,7 @@ public void SendPromptAsync_FreshSessionConfig_IncludesSkillDirectories() { // STRUCTURAL REGRESSION GUARD: The "Session not found" fallback must assign // SkillDirectories in the freshConfig so skills survive reconnection. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); var helperIdx = source.IndexOf("BuildFreshSessionConfig(SessionState state"); Assert.True(helperIdx > 0, "Could not find BuildFreshSessionConfig helper"); @@ -310,7 +310,7 @@ public void SendPromptAsync_FreshSessionConfig_IncludesSystemMessage() { // STRUCTURAL REGRESSION GUARD: The "Session not found" fallback must include // SystemMessage so the session retains its system prompt after reconnection. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); var helperIdx = source.IndexOf("BuildFreshSessionConfig(SessionState state"); Assert.True(helperIdx > 0, "Could not find BuildFreshSessionConfig helper"); @@ -324,7 +324,7 @@ public void SendPromptAsync_FreshSessionConfig_MatchesCreateSessionFields() { // STRUCTURAL REGRESSION GUARD: The BuildFreshSessionConfig helper must // set the same critical fields as the original CreateSessionAsync config. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); var helperIdx = source.IndexOf("BuildFreshSessionConfig(SessionState state"); Assert.True(helperIdx > 0); @@ -451,7 +451,7 @@ public void RestorePreviousSessions_FallbackCoversProcessErrors() // STRUCTURAL REGRESSION GUARD: RestorePreviousSessionsAsync must include // IsProcessError in the fallback condition so worker sessions with stale CLI // server process handles get recreated instead of silently dropped. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); // Find the fallback condition that checks ex.Message for "Session not found" var conditionIndex = source.IndexOf("ex.Message.Contains(\"Session not found\""); @@ -586,7 +586,7 @@ public void SendPromptAsync_ReconnectPath_HasLazyReinitializationGuard() // must check for a dead client (!IsInitialized || _client == null) before // calling GetClientForGroup. Without this, a previous reconnect failure // that nulled _client makes ALL sessions permanently dead. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); // Find the reconnect block (inside the SendAsync catch) var reconnectIndex = source.IndexOf("attempting reconnect..."); @@ -607,7 +607,7 @@ public void SendPromptAsync_LazyReinit_AttemptsServerRestart() { // STRUCTURAL REGRESSION GUARD: The lazy re-init path must attempt to restart // the persistent server (via _serverManager) before creating a new client. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); var reinitIndex = source.IndexOf("lazy re-initialization so the session can self-heal"); Assert.True(reinitIndex > 0); @@ -624,7 +624,7 @@ public void SendPromptAsync_LazyReinit_SkipsForCodespaceSessions() { // STRUCTURAL REGRESSION GUARD: Lazy re-init should NOT run for codespace // sessions (they have their own health check reconnection mechanism). - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); var reinitIndex = source.IndexOf("lazy re-initialization so the session can self-heal"); Assert.True(reinitIndex > 0); @@ -713,7 +713,7 @@ public void ResumeSessionAsync_Structural_DedupsDuplicateDisplayName() // STRUCTURAL REGRESSION GUARD: ResumeSessionAsync must de-duplicate // display names instead of throwing on collision. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); var methodIndex = source.IndexOf("public async Task ResumeSessionAsync"); Assert.True(methodIndex > 0, "Could not find ResumeSessionAsync method"); @@ -755,7 +755,7 @@ public void ChatDatabase_AddMessageAsync_HasBroadCatch() // STRUCTURAL REGRESSION GUARD: AddMessageAsync must use `catch (Exception` // not a narrow filter like `catch (SQLiteException)`. Historical regression // used narrow catch → uncaught exceptions became UnobservedTaskException. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "ChatDatabase.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "ChatDatabase.cs")); // Find the AddMessageAsync method var methodIndex = source.IndexOf("public async Task AddMessageAsync"); @@ -773,8 +773,8 @@ public void AllChatDbCalls_UseSafeFireAndForget() { // STRUCTURAL REGRESSION GUARD: All fire-and-forget _chatDb calls in // CopilotService must use SafeFireAndForget, not bare `_ = ...`. - var csFile = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); - var eventsFile = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + var csFile = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); + var eventsFile = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); // No bare fire-and-forget patterns should exist Assert.DoesNotContain("_ = _chatDb.", csFile); diff --git a/PolyPilot.Tests/ConsecutiveStuckSessionTests.cs b/PolyPilot.Tests/ConsecutiveStuckSessionTests.cs index bd9ea36ea2..a6d89da5d1 100644 --- a/PolyPilot.Tests/ConsecutiveStuckSessionTests.cs +++ b/PolyPilot.Tests/ConsecutiveStuckSessionTests.cs @@ -155,7 +155,7 @@ public void WatchdogSource_IncrementsConsecutiveStuckCount() // Verify the watchdog timeout path increments ConsecutiveStuckCount. // This is a structural guard — if someone removes the increment, this test fails. var repoRoot = GetRepoRoot(); - var eventsSource = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot", "Services", "CopilotService.Events.cs")); + var eventsSource = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot.Core", "Services", "CopilotService.Events.cs")); Assert.Contains("ConsecutiveStuckCount++", eventsSource); } @@ -166,7 +166,7 @@ public void WatchdogSource_SkipsHistoryGrowthAfterRepeatedStucks() // Verify the watchdog conditionally skips adding system messages to history // when ConsecutiveStuckCount >= 3 to break the positive feedback loop. var repoRoot = GetRepoRoot(); - var eventsSource = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot", "Services", "CopilotService.Events.cs")); + var eventsSource = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot.Core", "Services", "CopilotService.Events.cs")); Assert.Contains("ConsecutiveStuckCount < 3", eventsSource); } @@ -177,7 +177,7 @@ public void WatchdogSource_ClearsMessageQueueOnRepeatedStucks() // Verify that after 3+ consecutive stucks, the message queue is cleared // to prevent auto-dispatch from immediately re-sending into a stuck session. var repoRoot = GetRepoRoot(); - var eventsSource = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot", "Services", "CopilotService.Events.cs")); + var eventsSource = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot.Core", "Services", "CopilotService.Events.cs")); // The else branch (ConsecutiveStuckCount >= 3) must clear the queue Assert.Contains("MessageQueue.Clear()", eventsSource); @@ -188,7 +188,7 @@ public void CompleteResponseSource_ResetsConsecutiveStuckCount() { // Verify CompleteResponse resets the stuck counter on success. var repoRoot = GetRepoRoot(); - var eventsSource = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot", "Services", "CopilotService.Events.cs")); + var eventsSource = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot.Core", "Services", "CopilotService.Events.cs")); Assert.Contains("ConsecutiveStuckCount = 0", eventsSource); } @@ -287,7 +287,7 @@ public void SendAsyncTimeoutSource_WrapsWithTaskWhenAny() // Verify that SendAsync is wrapped with Task.WhenAny for timeout detection. // The CancellationToken.None workaround remains, but we have a client-side timeout. var repoRoot = GetRepoRoot(); - var source = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot.Core", "Services", "CopilotService.cs")); Assert.Contains("Task.WhenAny(sendTask", source); Assert.Contains("SendAsyncTimeoutMs", source); @@ -299,7 +299,7 @@ public void SendAsyncTimeoutSource_RetryPathAlsoHasTimeout() // Verify that the reconnect+retry SendAsync path ALSO has the timeout wrapper. // Without this, the retry could hang indefinitely just like the primary send. var repoRoot = GetRepoRoot(); - var source = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot.Core", "Services", "CopilotService.cs")); Assert.Contains("Task.WhenAny(retryTask", source); } diff --git a/PolyPilot.Tests/LongRunningSessionSafetyTests.cs b/PolyPilot.Tests/LongRunningSessionSafetyTests.cs index 9805baefbc..54c7e99425 100644 --- a/PolyPilot.Tests/LongRunningSessionSafetyTests.cs +++ b/PolyPilot.Tests/LongRunningSessionSafetyTests.cs @@ -71,7 +71,7 @@ private CopilotService CreateService() => private static class TestPaths { private static readonly string ProjectRoot = Path.GetFullPath( - Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PolyPilot")); + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PolyPilot.Core")); public static string CopilotServiceCs => Path.Combine(ProjectRoot, "Services", "CopilotService.cs"); public static string EventsCs => Path.Combine(ProjectRoot, "Services", "CopilotService.Events.cs"); diff --git a/PolyPilot.Tests/MultiAgentRegressionTests.cs b/PolyPilot.Tests/MultiAgentRegressionTests.cs index eb5b819709..ead49e1aeb 100644 --- a/PolyPilot.Tests/MultiAgentRegressionTests.cs +++ b/PolyPilot.Tests/MultiAgentRegressionTests.cs @@ -1448,7 +1448,7 @@ public void DiagnosticLogFilter_IncludesDispatchTag() // The Debug() method's file filter must include [DISPATCH] so orchestration // events are written to event-diagnostics.log for post-mortem analysis. // This was a bug: [DISPATCH] was written to Console but not persisted. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); Assert.Contains("[DISPATCH", source.Substring(source.IndexOf("message.StartsWith(\"[EVT"))); } @@ -1458,7 +1458,7 @@ public void ReconnectState_ShouldCarryIsMultiAgentSession() // After reconnect in SendPromptAsync, the new SessionState must carry forward // IsMultiAgentSession from the old state. Without this, the watchdog uses the // 120s inactivity timeout instead of 600s, killing long-running worker tasks. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); // Find the reconnect block where state is replaced var marker = "[RECONNECT] '{sessionName}' replacing state"; @@ -1479,7 +1479,7 @@ public void MonitorAndSynthesize_ShouldFilterByDispatchTimestamp() // MonitorAndSynthesizeAsync must filter worker results by dispatch timestamp // to avoid picking up stale pre-dispatch assistant messages from prior conversations. // This was a 3/3 consensus finding from multi-model review. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs")); // Find the result collection section in MonitorAndSynthesizeAsync var monitorSection = source.Substring(source.IndexOf("Collect worker results from their chat history")); @@ -1497,7 +1497,7 @@ public void PendingOrchestration_ShouldClearInFinallyBlock() // ClearPendingOrchestration must be in a finally block so it's cleaned up // even on cancellation/error. Otherwise stale pending files cause spurious // resume on next launch. Opus review finding. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs")); // Non-reflect path: must have finally { ClearPendingOrchestration } var nonReflectDispatch = source.Substring(source.IndexOf("Phase 3: Dispatch tasks to workers")); @@ -1525,7 +1525,7 @@ public void MonitorAndSynthesize_ShouldRedispatchUnstartedWorkers() // When the app restarts and workers never started (TaskCanceledException killed dispatch), // MonitorAndSynthesizeAsync should detect idle workers with no post-dispatch response // and re-dispatch them instead of reporting "no response found". - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs")); var startIdx = source.IndexOf("private async Task MonitorAndSynthesizeAsync"); Assert.True(startIdx >= 0, "MonitorAndSynthesizeAsync method not found in source"); @@ -1566,7 +1566,7 @@ public void RetryOrchestration_ResetsReflectState() { // RetryOrchestrationAsync should reset the reflect state so the loop can re-enter. // If ReflectionState.IsActive is false (loop completed), retry should re-activate it. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs")); var startIdx = source.IndexOf("public async Task RetryOrchestrationAsync"); Assert.True(startIdx >= 0, "RetryOrchestrationAsync method not found in source"); @@ -1588,7 +1588,7 @@ public void RetryOrchestration_FallsBackToResumePrompt() { // When no explicit prompt is given and no user message found in history, // RetryOrchestrationAsync should use a fallback resume instruction. - var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs")); + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs")); var startIdx = source.IndexOf("public async Task RetryOrchestrationAsync"); Assert.True(startIdx >= 0, "RetryOrchestrationAsync method not found in source"); @@ -2391,7 +2391,7 @@ public void PrematureIdleSignal_ExistsOnSessionState() public void PrematureIdleSignal_SetInRearmPath() { // Structural: the EVT-REARM path must call PrematureIdleSignal.Set() - var eventsPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs"); + var eventsPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs"); var source = File.ReadAllText(eventsPath); // Find the EVT-REARM block @@ -2407,7 +2407,7 @@ public void PrematureIdleSignal_SetInRearmPath() public void PrematureIdleSignal_ResetInSendPromptAsync() { // Structural: SendPromptAsync must reset PrematureIdleSignal on each new turn - var servicePath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs"); + var servicePath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs"); var source = File.ReadAllText(servicePath); // Find SendPromptAsync method @@ -2422,7 +2422,7 @@ public void PrematureIdleSignal_ResetInSendPromptAsync() public void RecoverFromPrematureIdleIfNeededAsync_ExistsInOrganization() { // Structural: the recovery method must exist and be called from ExecuteWorkerAsync - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); Assert.Contains("RecoverFromPrematureIdleIfNeededAsync", source); @@ -2439,7 +2439,7 @@ public void RecoverFromPrematureIdleIfNeededAsync_OnlyForMultiAgentSessions() { // Structural: the recovery check must be guarded by IsMultiAgentSession // to avoid adding latency to normal single-session completions - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); var execIdx = source.IndexOf("private async Task ExecuteWorkerAsync", StringComparison.Ordinal); @@ -2458,7 +2458,7 @@ public void RecoverFromPrematureIdleIfNeededAsync_SubscribesToOnSessionComplete( { // Structural: the recovery method must subscribe to OnSessionComplete to detect // the worker's real completion (after premature idle re-arm) - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); // Find the method definition (not a call site) @@ -2474,7 +2474,7 @@ public void RecoverFromPrematureIdleIfNeededAsync_SubscribesToOnSessionComplete( public void RecoverFromPrematureIdleIfNeededAsync_HasDiskFallback() { // Structural: if History doesn't have full content, fall back to LoadHistoryFromDisk - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); // Find the method definition (not a call site) @@ -2489,7 +2489,7 @@ public void RecoverFromPrematureIdleIfNeededAsync_HasDiskFallback() public void RecoverFromPrematureIdleIfNeededAsync_HasDiagnosticLogging() { // Every recovery path must have diagnostic log entries - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); // Find the method definition (not a call site) @@ -2505,7 +2505,7 @@ public void MutationBeforeCommit_SessionIdSetAfterTryUpdate() { // Structural: SessionId must be set AFTER TryUpdate succeeds, not before. // This prevents mutating shared Info on a path that might discard the state. - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); // Find the revival block in ExecuteWorkerAsync @@ -2529,7 +2529,7 @@ public void RecoverFromPrematureIdleIfNeededAsync_UsesEventsFileFreshness() // Structural: recovery must check events.jsonl freshness as a parallel detection // signal alongside WasPrematurelyIdled flag. This catches cases where EVT-REARM // takes 30-60s to fire but the CLI is still writing events. - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); var methodIdx = source.IndexOf("private async Task RecoverFromPrematureIdleIfNeededAsync", StringComparison.Ordinal); @@ -2543,7 +2543,7 @@ public void RecoverFromPrematureIdleIfNeededAsync_UsesEventsFileFreshness() public void IsEventsFileActive_HelperExists() { // Structural: IsEventsFileActive must exist as a helper for events.jsonl freshness checks - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); var helperIdx = source.IndexOf("private bool IsEventsFileActive(", StringComparison.Ordinal); @@ -2560,7 +2560,7 @@ public void RecoverFromPrematureIdleIfNeededAsync_LoopsOnRepeatedPrematureIdle() // Structural: recovery must loop to handle repeated premature idle (observed: 4x in a row). // After each OnSessionComplete, it checks if events.jsonl is still active before deciding // the worker is truly done. - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); var methodIdx = source.IndexOf("private async Task RecoverFromPrematureIdleIfNeededAsync", StringComparison.Ordinal); @@ -2578,7 +2578,7 @@ public void RecoverFromPrematureIdleIfNeededAsync_LoopsOnRepeatedPrematureIdle() public void PrematureIdleEventsFileFreshnessSeconds_ConstantExists() { // The freshness threshold constant must exist and be reasonable (10-60s range) - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); Assert.Contains("PrematureIdleEventsFileFreshnessSeconds", source); @@ -2598,7 +2598,7 @@ public void PrematureIdleEventsGracePeriodMs_ConstantExists() Assert.True(CopilotService.PrematureIdleEventsGracePeriodMs <= 5000, "Grace period must be <= 5s to not delay normal completions excessively"); - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); var constIdx = source.IndexOf("internal const int PrematureIdleEventsGracePeriodMs", StringComparison.Ordinal); Assert.True(constIdx >= 0, "PrematureIdleEventsGracePeriodMs must be an internal const int"); @@ -2609,7 +2609,7 @@ public void GetEventsFileMtime_HelperExists() { // GetEventsFileMtime must exist as an internal helper returning DateTime? // Used by RecoverFromPrematureIdleIfNeededAsync for mtime-comparison detection. - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); var helperIdx = source.IndexOf("internal DateTime? GetEventsFileMtime(", StringComparison.Ordinal); @@ -2626,7 +2626,7 @@ public void RecoverFromPrematureIdleIfNeededAsync_UsesMtimeComparisonForInitialD // Structural: instead of raw IsEventsFileActive (which sees the idle event's own write // as "fresh" and false-positives), the method must snapshot mtime, wait the grace period, // then compare mtimes. Only a changed mtime proves the CLI is still writing. - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); var methodIdx = source.IndexOf("private async Task RecoverFromPrematureIdleIfNeededAsync", StringComparison.Ordinal); @@ -2651,7 +2651,7 @@ public void RecoverFromPrematureIdleIfNeededAsync_PollingLoopUsesMtimeComparison // Structural: the secondary polling loop must also use mtime comparison (not raw // IsEventsFileActive) so that a stale-but-fresh file doesn't trigger false detection // in subsequent poll cycles. - var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Organization.cs"); + var orgPath = Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Organization.cs"); var source = File.ReadAllText(orgPath); var methodIdx = source.IndexOf("private async Task RecoverFromPrematureIdleIfNeededAsync", StringComparison.Ordinal); diff --git a/PolyPilot.Tests/PermissionDenialDetectionTests.cs b/PolyPilot.Tests/PermissionDenialDetectionTests.cs index bddab3e222..034fbe4412 100644 --- a/PolyPilot.Tests/PermissionDenialDetectionTests.cs +++ b/PolyPilot.Tests/PermissionDenialDetectionTests.cs @@ -236,7 +236,7 @@ public void RecoverableError_IncludesMcpFailure() // STRUCTURAL REGRESSION GUARD: isRecoverableError must include isMcpFailure // so that repeated MCP errors trigger auto-recovery (session reconnect). var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); Assert.Contains("var isRecoverableError = isPermissionDenial || isShellFailure || isMcpFailure;", source); } @@ -245,7 +245,7 @@ public void McpRecoveryMessage_IncludesReloadHint() { // When MCP recovery triggers, the user message must mention /mcp reload var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); Assert.Contains("/mcp reload", source); } @@ -270,7 +270,7 @@ public void ReloadMcpServersAsync_PreservesHistoryInPlace() // in-place using _sessions.TryUpdate (preserving AgentSessionInfo/history). // It must NOT call CreateSessionAsync with a renamed session (which loses history). var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); Assert.Contains("_sessions.TryUpdate(sessionName, newState, state)", source); Assert.Contains("Info = state.Info", source); } @@ -281,7 +281,7 @@ public void ReloadMcpServersAsync_ThrowsOnConcurrentReload() // STRUCTURAL: Concurrent reload must throw (not silently succeed) so the Dashboard // can display an honest error message instead of a false "✅ reloaded" confirmation. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); // The guard must throw, not return silently Assert.Contains("throw new InvalidOperationException(\"MCP reload is already in progress", source); } @@ -299,7 +299,7 @@ public void ReloadMcpServersAsync_ClearsPermissionDenials_Unconditionally() // accompanies the out-of-if-block call. If ClearPermissionDenials() were moved back // inside the if-block, this comment would not be present and the test would fail. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); // The unconditional call is preceded by a comment that is unique to that call site. Assert.Contains("Always clear the sliding-window denial queue regardless of processing state", source); @@ -379,7 +379,7 @@ public void IsInitializationError_HelperIsInternal_CanBeTestedViaPublicSurface() // The helper is internal — verify it's accessible from tests (InternalsVisibleTo). // Also verify the helper exists in CopilotService.Utilities.cs at the right location. var repoRoot = GetRepoRoot(); - var source = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot", "Services", "CopilotService.Utilities.cs")); + var source = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot.Core", "Services", "CopilotService.Utilities.cs")); Assert.Contains("internal static bool IsInitializationError(Exception ex)", source); Assert.Contains("not initialized", source); } @@ -390,7 +390,7 @@ public void ExecuteWorkerAsync_RetryGate_IncludesInitializationError() // Structural: the retry catch in ExecuteWorkerAsync must check IsInitializationError // so "Service not initialized" triggers a retry with lazy re-init, not an immediate fail. var repoRoot = GetRepoRoot(); - var source = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot", "Services", "CopilotService.Organization.cs")); + var source = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot.Core", "Services", "CopilotService.Organization.cs")); Assert.Contains("IsInitializationError(ex)", source); // The retry gate must combine both checks Assert.Contains("IsConnectionError(ex) || IsInitializationError(ex)", source); diff --git a/PolyPilot.Tests/PlatformHelperTests.cs b/PolyPilot.Tests/PlatformHelperTests.cs index 051fc91100..9f74fac2a8 100644 --- a/PolyPilot.Tests/PlatformHelperTests.cs +++ b/PolyPilot.Tests/PlatformHelperTests.cs @@ -18,6 +18,32 @@ public void IsMobile_OnTestHost_IsFalse() Assert.False(PlatformHelper.IsMobile); } + [Fact] + public void IsDesktop_And_IsMobile_AreExclusive() + { + // Desktop and mobile must never both be true at the same time + Assert.NotEqual(PlatformHelper.IsDesktop, PlatformHelper.IsMobile); + } + + [Fact] + public void PlatformName_OnTestHost_IsKnownValue() + { + // On a desktop test host, PlatformName should be a recognized value, never "unknown" + var name = PlatformHelper.PlatformName; + Assert.Contains(name, new[] { "maccatalyst", "windows", "linux" }); + } + + [Fact] + public void PlatformName_NeverReturnsUnknown_OnDesktopHost() + { + // Regression guard: runtime checks must correctly identify the platform. + // OperatingSystem.IsIOS() returns true on Mac Catalyst, so the check order matters. + if (PlatformHelper.IsDesktop) + { + Assert.NotEqual("unknown", PlatformHelper.PlatformName); + } + } + [Fact] public void AvailableModes_OnNonDesktop_IncludesRemote() { diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index 96472642db..9aae6b3792 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -9,13 +9,10 @@ + - - - - @@ -24,85 +21,15 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/PolyPilot.Tests/ProcessingWatchdogTests.cs b/PolyPilot.Tests/ProcessingWatchdogTests.cs index 5433cedf58..a6c65c3f58 100644 --- a/PolyPilot.Tests/ProcessingWatchdogTests.cs +++ b/PolyPilot.Tests/ProcessingWatchdogTests.cs @@ -2196,7 +2196,7 @@ public void WatchdogDecision_ActiveTool_ServerAlive_ShouldResetTimer() // the watchdog must reset the inactivity timer rather than killing the session. // This is verified via source-code assertion (since SessionState is private). var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var elapsedIdx = source.IndexOf("elapsed >= effectiveTimeout", methodIdx); // Find the end of the method dynamically (next top-level member after RunProcessingWatchdogAsync) @@ -2230,7 +2230,7 @@ public void WatchdogDecision_NoActiveTool_ToolsWereUsed_ShouldCompleteCleanly() // the watchdog must complete the session CLEANLY (call CompleteResponse, no error msg). // This is the "SessionIdleEvent lost" scenario — response is done, just terminal event missed. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var elapsedIdx = source.IndexOf("elapsed >= effectiveTimeout", methodIdx); var methodEndIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); @@ -2264,7 +2264,7 @@ public void WatchdogDecision_MaxTimeExceeded_AlwaysKills() // When total processing time exceeds WatchdogMaxProcessingTimeSeconds, the watchdog // must kill regardless of server liveness or tool state — no session runs forever. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var elapsedIdx = source.IndexOf("elapsed >= effectiveTimeout", methodIdx); var methodEndIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); @@ -2287,7 +2287,7 @@ public void WatchdogPeriodicFlush_HasGenerationGuard() // validate it inside the lambda — preventing stale watchdog ticks from flushing // new-turn content into old-turn history if the user aborts + resends. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var flushCommentIdx = source.IndexOf("Periodic mid-watchdog flush", methodIdx); Assert.True(flushCommentIdx > 0, "Periodic flush comment must exist in RunProcessingWatchdogAsync"); @@ -2313,7 +2313,7 @@ public void ExternalToolRequestedEvent_IsInEventMatrix() // ExternalToolRequestedEvent was arriving as "Unhandled" in logs, causing spam // and incorrectly updating LastEventAtTicks. It must be explicitly classified. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); Assert.True(source.Contains("ExternalToolRequestedEvent"), "ExternalToolRequestedEvent must be explicitly listed in SdkEventMatrix to prevent 'Unhandled' log spam"); } @@ -2324,7 +2324,7 @@ public void ExternalToolRequestedEvent_ClassifiedAsTimelineOnly() // Must be TimelineOnly — it doesn't need chat projection but should not suppress // LastEventAtTicks updates (it does represent live activity on the session). var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var idx = source.IndexOf("ExternalToolRequestedEvent"); Assert.True(idx >= 0); var context = source.Substring(idx, 80); @@ -2340,7 +2340,7 @@ public void WatchdogPeriodicFlush_PresenceInSource() // The watchdog must flush CurrentResponse to History periodically so partial // responses are visible even while IsProcessing=true (e.g., stuck mid-stream). var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); // The flush must be inside the watchdog method var watchdogBody = source.Substring(methodIdx, 6000); @@ -2356,7 +2356,7 @@ public void WatchdogPeriodicFlush_RunsBeforeTimeoutCheck() // The periodic flush must run BEFORE the elapsed >= effectiveTimeout check, // so content is visible even before the session is declared stuck. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var flushIdx = source.IndexOf("Periodic mid-watchdog flush", methodIdx); var elapsedCheckIdx = source.IndexOf("elapsed >= effectiveTimeout", methodIdx); @@ -2376,7 +2376,7 @@ public void WatchdogDecision_MultiAgent_NoTools_CaseBIncludesMultiAgent_InSource // follow-on sub-turn. Without this, the 120s watchdog fires Case C (error kill) instead // of Case B (clean complete), showing an unnecessary error message. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); var watchdogBody = source.Substring(methodIdx, endIdx - methodIdx); @@ -2419,7 +2419,7 @@ public void AssistantTurnStartEvent_LoggedInEvtDiagnostics_InSource() // event-diagnostics.log shows a gap with no explanation — making stuck-session // forensics impossible (root cause of the PR #332 debug session bug). var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var evtLogIdx = source.IndexOf("[EVT]"); Assert.True(evtLogIdx >= 0, "[EVT] log must exist in event handler"); // The EVT filter block must include AssistantTurnStartEvent @@ -2475,7 +2475,7 @@ public void CaseA_ExceedingMaxResets_FallsThroughToKill_InSource() // This is the core fix for the stuck-session bug where a dead session's tool // appears active but no events ever arrive. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); Assert.True(methodIdx >= 0, "RunProcessingWatchdogAsync must exist"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); @@ -2496,7 +2496,7 @@ public void CaseA_ResetCounter_ClearedOnRealEvents_InSource() // must be cleared. This proves the session's connection is alive, so future // Case A resets should be fresh (not counting against the cap). var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var handlerIdx = source.IndexOf("private void HandleSessionEvent"); Assert.True(handlerIdx >= 0, "HandleSessionEvent must exist"); // Find the block that resets LastEventAtTicks (only for real events) @@ -2515,7 +2515,7 @@ public void CaseA_ResetCounter_ClearedOnWatchdogStart_InSource() // StartProcessingWatchdog must reset WatchdogCaseAResets to 0 so each new // watchdog instance starts with a clean reset counter. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var startIdx = source.IndexOf("private void StartProcessingWatchdog"); Assert.True(startIdx >= 0, "StartProcessingWatchdog must exist"); var methodEnd = source.IndexOf("_ = RunProcessingWatchdogAsync", startIdx); @@ -2531,7 +2531,7 @@ public void ExceededMaxTime_TrueWhenProcessingStartedAtNull_InSource() // exceededMaxTime must be true. Without this, Case A can reset forever // because totalProcessingSeconds=0 makes exceededMaxTime always false. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); Assert.True(methodIdx >= 0, "RunProcessingWatchdogAsync must exist"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); @@ -2558,7 +2558,7 @@ public void ReconnectPath_ResetsHasUsedToolsThisTurn_InSource() // uses the 120s inactivity timeout, not the 600s tool timeout inherited from // the dead connection. Without this, reconnected sessions wait 5x longer. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); var reconnectIdx = source.IndexOf("[RECONNECT] '{sessionName}' replacing state"); Assert.True(reconnectIdx >= 0, "Reconnect block must exist"); // Find StartProcessingWatchdog in the reconnect block (marks the end of state setup) @@ -2579,7 +2579,7 @@ public void ReconnectPath_ResetsProcessingStartedAt_InSource() // so the watchdog's max-time safety net measures from the reconnect time, // not the original send time. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); var reconnectIdx = source.IndexOf("[RECONNECT] '{sessionName}' replacing state"); Assert.True(reconnectIdx >= 0, "Reconnect block must exist"); var watchdogIdx = source.IndexOf("StartProcessingWatchdog(state, sessionName)", reconnectIdx); @@ -2596,7 +2596,7 @@ public void ReconnectPath_ResetsActiveToolCallCount_InSource() // After reconnect, ActiveToolCallCount must be 0. No tools have started // on the new connection. A stale count > 0 would trigger Case A resets. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); var reconnectIdx = source.IndexOf("[RECONNECT] '{sessionName}' replacing state"); Assert.True(reconnectIdx >= 0, "Reconnect block must exist"); var watchdogIdx = source.IndexOf("StartProcessingWatchdog(state, sessionName)", reconnectIdx); @@ -2641,7 +2641,7 @@ public void WatchdogCaseB_UsesMultiAgentFreshness_InSource() // Case B freshness check must select threshold based on isMultiAgentSession. // This prevents premature force-completion of worker sessions (issue #365). var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); var watchdogBody = source.Substring(methodIdx, endIdx - methodIdx); @@ -2665,7 +2665,7 @@ public void WatchdogCaseB_FreshnessThresholdSelection_InSource() // Verify that the freshness threshold is selected based on isMultiAgentSession // using a ternary before the events.jsonl check. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); var watchdogBody = source.Substring(methodIdx, endIdx - methodIdx); @@ -2686,7 +2686,7 @@ public void WatchdogCatchBlock_ClearsIsProcessing_InSource() // MUST clear IsProcessing — otherwise the session is permanently stuck. // Regression test for: sessions stuck at "Sending..." forever after watchdog crash. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); var watchdogBody = source.Substring(methodIdx, endIdx - methodIdx); @@ -2719,7 +2719,7 @@ public void WatchdogCatchBlock_CompletesResponseCompletion_InSource() // The crash recovery must complete the TCS so callers (e.g., orchestrators) // waiting on SendPromptAsync aren't blocked forever. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); var watchdogBody = source.Substring(methodIdx, endIdx - methodIdx); @@ -2746,7 +2746,7 @@ public void WatchdogKillCallback_ProtectsFlushCurrentResponse_InSource() // wrapped in try-catch within the kill callback. // Regression test for: sessions permanently stuck after FlushCurrentResponse failure. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); var watchdogBody = source.Substring(methodIdx, endIdx - methodIdx); @@ -2927,7 +2927,7 @@ public void WatchdogCaseB_FileSizeGrowthCheck_InSource() // window (especially the 1800s multi-agent window). Without a growth check, // multi-agent sessions stay stuck for up to 30 minutes. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); var watchdogBody = source.Substring(methodIdx, endIdx - methodIdx); @@ -2955,7 +2955,7 @@ public void WatchdogCaseB_StaleDetection_SkipsDeferral_InSource() // When stale checks exceed the max, caseBEventsActive must be set to false // so the deferral is skipped and the session is force-completed. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); var watchdogBody = source.Substring(methodIdx, endIdx - methodIdx); @@ -2975,7 +2975,7 @@ public void WatchdogCaseB_GrowthResetsStaleCount_InSource() // When events.jsonl grows between Case B checks, the stale counter must reset. // This prevents false positives when the CLI is actively writing but slowly. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); var watchdogBody = source.Substring(methodIdx, endIdx - methodIdx); @@ -3011,7 +3011,7 @@ public void WatchdogCaseB_StaleFieldsResetOnEventArrival_InSource() // When real SDK events arrive, the Case B stale tracking must reset alongside // WatchdogCaseBResets. This ensures a revived connection clears stale state. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); // Find the event handler section where CaseBResets is reset on real event arrival var handlerSection = source.Substring(0, source.IndexOf("private async Task RunProcessingWatchdogAsync")); @@ -3029,7 +3029,7 @@ public void WatchdogCaseB_StaleFieldsResetOnWatchdogStart_InSource() // StartProcessingWatchdog must reset the new stale tracking fields // alongside the existing WatchdogCaseBResets reset. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); // Find the StartProcessingWatchdog method var startIdx = source.IndexOf("private void StartProcessingWatchdog("); @@ -3049,7 +3049,7 @@ public void WatchdogCaseB_UsesFileInfoForSizeAndTime_InSource() // filesystem call, avoiding a TOCTOU race between separate File.GetLastWriteTimeUtc // and FileInfo.Length calls. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Events.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Events.cs")); var methodIdx = source.IndexOf("private async Task RunProcessingWatchdogAsync"); var endIdx = source.IndexOf(" private readonly ConcurrentDictionary", methodIdx); var watchdogBody = source.Substring(methodIdx, endIdx - methodIdx); diff --git a/PolyPilot.Tests/RenderThrottleTests.cs b/PolyPilot.Tests/RenderThrottleTests.cs index eae7d1f4cc..f2d7f4378f 100644 --- a/PolyPilot.Tests/RenderThrottleTests.cs +++ b/PolyPilot.Tests/RenderThrottleTests.cs @@ -205,7 +205,7 @@ public void CompleteResponse_OnSessionComplete_FiresBeforeOnStateChanged() // to bypass throttle. If order is reversed, throttle drops the render. var eventsPath = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", - "PolyPilot", "Services", "CopilotService.Events.cs"); + "PolyPilot.Core", "Services", "CopilotService.Events.cs"); Assert.True(File.Exists(eventsPath), $"CopilotService.Events.cs not found at {eventsPath}"); var source = File.ReadAllText(eventsPath); diff --git a/PolyPilot.Tests/SessionPersistenceTests.cs b/PolyPilot.Tests/SessionPersistenceTests.cs index 991942ed84..4f13561b0e 100644 --- a/PolyPilot.Tests/SessionPersistenceTests.cs +++ b/PolyPilot.Tests/SessionPersistenceTests.cs @@ -458,7 +458,7 @@ public void MergeSessionEntries_PreservesLastPrompt() public void RestorePreviousSessionsAsync_QueuesEagerResumeForInterruptedSessions() { var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var placeholderIdx = source.IndexOf("Loaded session placeholder", StringComparison.Ordinal); Assert.True(placeholderIdx > 0, "Placeholder restore block not found"); @@ -472,7 +472,7 @@ public void RestorePreviousSessionsAsync_QueuesEagerResumeForInterruptedSessions public void RestorePreviousSessionsAsync_RunsInterruptedSessionResumesAfterPlaceholderLoad() { var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var loadIdx = source.IndexOf("Loaded session placeholder", StringComparison.Ordinal); var eagerResumeIdx = source.IndexOf("await EnsureSessionConnectedAsync(pendingResume.SessionName, pendingResume.State, cancellationToken)", StringComparison.Ordinal); @@ -551,7 +551,7 @@ public void RestoreFallback_LoadsHistoryFromOldSession() // RestorePreviousSessionsAsync must call LoadHistoryFromDisk(entry.SessionId) // before CreateSessionAsync so conversation history is recovered. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var fallbackIdx = source.IndexOf("Falling back to CreateSessionAsync", StringComparison.Ordinal); Assert.True(fallbackIdx > 0, "Could not find fallback path in RestorePreviousSessionsAsync"); @@ -569,7 +569,7 @@ public void RestoreFallback_InjectsHistoryIntoRecreatedSession() // STRUCTURAL REGRESSION GUARD: After CreateSessionAsync, the fallback must // inject the recovered history into the new session's Info.History. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var fallbackIdx = source.IndexOf("Falling back to CreateSessionAsync", StringComparison.Ordinal); Assert.True(fallbackIdx > 0); @@ -585,7 +585,7 @@ public void RestoreFallback_RestoresUsageStats() // STRUCTURAL REGRESSION GUARD: The fallback must call RestoreUsageStats(entry) // to preserve token counts, CreatedAt, and other stats from the old session. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var fallbackIdx = source.IndexOf("Falling back to CreateSessionAsync", StringComparison.Ordinal); Assert.True(fallbackIdx > 0); @@ -600,7 +600,7 @@ public void RestoreFallback_SyncsHistoryToDatabase() // STRUCTURAL REGRESSION GUARD: The fallback must sync recovered history // to the chat database under the new session ID so it persists. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var fallbackIdx = source.IndexOf("Falling back to CreateSessionAsync", StringComparison.Ordinal); Assert.True(fallbackIdx > 0); @@ -616,7 +616,7 @@ public void RestoreFallback_CopiesEventsJsonlToNewSessionDirectory() // events.jsonl into the recreated session directory so a later restart // can reload history from the new session ID. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var fallbackIdx = source.IndexOf("Falling back to CreateSessionAsync", StringComparison.Ordinal); if (fallbackIdx < 0) @@ -635,7 +635,7 @@ public void RestoreFallback_NormalizesIncompleteToolAndReasoningEntries() // STRUCTURAL REGRESSION GUARD: The fallback must mark stale incomplete // tool-call and reasoning entries complete, matching ResumeSessionAsync. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var fallbackIdx = source.IndexOf("Falling back to CreateSessionAsync", StringComparison.Ordinal); Assert.True(fallbackIdx > 0); @@ -653,7 +653,7 @@ public void RestoreFallback_AddsReconnectionIndicator() // indicating the session was recreated with recovered history, so the // user knows the session state was reconstructed. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var fallbackIdx = source.IndexOf("Falling back to CreateSessionAsync", StringComparison.Ordinal); Assert.True(fallbackIdx > 0); @@ -671,7 +671,7 @@ public void RestoreFallback_MessageCount_SetAfterSystemMessage() // message ("🔄 Session recreated") isn't counted, and the unread indicator // doesn't trigger for it. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var fallbackIdx = source.IndexOf("Falling back to CreateSessionAsync", StringComparison.Ordinal); Assert.True(fallbackIdx > 0); @@ -705,7 +705,7 @@ public void InitializeAsync_FlushesSessionsAfterRestore() // Without this, active-sessions.json retains stale IDs and the next restart // reads the wrong events.jsonl, causing history loss. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); // Find the RestorePreviousSessionsAsync call in InitializeAsync var restoreCallIdx = source.IndexOf("await RestorePreviousSessionsAsync(cancellationToken);", StringComparison.Ordinal); @@ -722,7 +722,7 @@ public void ReconnectAsync_FlushesSessionsAfterRestore() // STRUCTURAL REGRESSION GUARD: Same as InitializeAsync — the ReconnectAsync // path must also flush after restore to persist recreated session IDs. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); // Find all RestorePreviousSessionsAsync calls var indices = new List(); @@ -756,7 +756,7 @@ public void Reconnect_CallsSaveActiveSessionsToDisk_AfterUpdatingSessionId() // and _sessions[sessionName] = newState, SaveActiveSessionsToDisk() must be // called so the new session ID is persisted immediately. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); // Find the specific assignment where the new state replaces the old one var sessionsAssign = source.IndexOf("_sessions[sessionName] = newState", StringComparison.Ordinal); @@ -776,7 +776,7 @@ public void Restore_DoesNotSkipSessionsBeforeFallbackCanHandleMissingEvents() // short-circuit on missing events.jsonl before the existing fallback path // can recreate legitimate never-used sessions. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var restoreIdx = source.IndexOf("RestorePreviousSessionsAsync", StringComparison.Ordinal); Assert.True(restoreIdx > 0); @@ -792,7 +792,7 @@ public void SaveActiveSessionsToDisk_AcceptsEventsOrRecentDirectories() // WriteActiveSessionsFile/SaveActiveSessionsToDisk must keep used sessions // via events.jsonl and also preserve newly created directories briefly. var source = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); var mergeCallIdx = source.IndexOf("MergeSessionEntries(entries", StringComparison.Ordinal); Assert.True(mergeCallIdx > 0); @@ -1415,7 +1415,7 @@ public void ResumeSessionAsync_ShouldUseActualSessionId_NotInputId() // Structural test verifying that ResumeSessionAsync uses // copilotSession.SessionId (actual) instead of sessionId (input). var serviceFile = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); // Verify the session ID mismatch detection block exists Assert.Contains("[RESUME-REMAP] Session ID changed", serviceFile); @@ -1432,7 +1432,7 @@ public void ReconnectPath_ShouldDetectSessionIdMismatch() { // Structural test verifying the reconnect path also detects ID mismatch var serviceFile = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.cs")); Assert.Contains("[RECONNECT] Session ID changed on resume", serviceFile); Assert.Contains("CopyEventsToNewSession(state.Info.SessionId, actualId)", serviceFile); @@ -1444,7 +1444,7 @@ public void FallbackRestore_ShouldUseFindBestEventsSource() // Structural test verifying the fallback restore path uses FindBestEventsSource // instead of always loading from the stale entry.SessionId var persistenceFile = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); Assert.Contains("FindBestEventsSource(entry.SessionId, entry.WorkingDirectory)", persistenceFile); Assert.Contains("LoadBestHistoryAsync(bestSourceId)", persistenceFile); @@ -1455,7 +1455,7 @@ public void CopyEventsToNewSession_SharedHelper_ExistsInPersistence() { // Structural test verifying CopyEventsToNewSession is a reusable helper var persistenceFile = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); // Should be a private method (not inlined copy logic) Assert.Contains("private void CopyEventsToNewSession(string oldSessionId, string newSessionId)", persistenceFile); @@ -1604,7 +1604,7 @@ public void FindBestEventsSource_LimitsDirectoryScan() { // Structural test: verify MaxSessionDirsToScan constant exists for performance bounding var persistenceFile = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); Assert.Contains("MaxSessionDirsToScan", persistenceFile); Assert.Contains(".Take(MaxSessionDirsToScan)", persistenceFile); @@ -1616,7 +1616,7 @@ public void CopyEventsToNewSession_CleansTempFileOnFailure() { // Structural test: verify temp file cleanup in catch block var persistenceFile = File.ReadAllText( - Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.Persistence.cs")); + Path.Combine(GetRepoRoot(), "PolyPilot.Core", "Services", "CopilotService.Persistence.cs")); // Verify tmpFile is declared outside try and cleaned in catch Assert.Contains("string? tmpFile = null;", persistenceFile); diff --git a/PolyPilot.Tests/SessionStabilityTests.cs b/PolyPilot.Tests/SessionStabilityTests.cs index babaf81f89..781da26c19 100644 --- a/PolyPilot.Tests/SessionStabilityTests.cs +++ b/PolyPilot.Tests/SessionStabilityTests.cs @@ -368,12 +368,14 @@ private static int CountPatterns(string text, string[] patterns) /// private static class TestPaths { - private static readonly string ProjectRoot = Path.GetFullPath( + private static readonly string CoreRoot = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PolyPilot.Core")); + private static readonly string UiRoot = Path.GetFullPath( Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PolyPilot")); - public static string CopilotServiceCs => Path.Combine(ProjectRoot, "Services", "CopilotService.cs"); - public static string EventsCs => Path.Combine(ProjectRoot, "Services", "CopilotService.Events.cs"); - public static string OrganizationCs => Path.Combine(ProjectRoot, "Services", "CopilotService.Organization.cs"); - public static string SessionSidebarRazor => Path.Combine(ProjectRoot, "Components", "Layout", "SessionSidebar.razor"); + public static string CopilotServiceCs => Path.Combine(CoreRoot, "Services", "CopilotService.cs"); + public static string EventsCs => Path.Combine(CoreRoot, "Services", "CopilotService.Events.cs"); + public static string OrganizationCs => Path.Combine(CoreRoot, "Services", "CopilotService.Organization.cs"); + public static string SessionSidebarRazor => Path.Combine(UiRoot, "Components", "Layout", "SessionSidebar.razor"); } } diff --git a/PolyPilot.Tests/SteeringMessageTests.cs b/PolyPilot.Tests/SteeringMessageTests.cs index 991bf82512..596d90576e 100644 --- a/PolyPilot.Tests/SteeringMessageTests.cs +++ b/PolyPilot.Tests/SteeringMessageTests.cs @@ -341,7 +341,7 @@ public async Task SteerSession_SoftSteerPath_ContainsUserMessageAddition() System.IO.Path.Combine( System.IO.Path.GetDirectoryName(typeof(CopilotService).Assembly.Location)! .Replace("PolyPilot.Tests", "PolyPilot"), - "..", "..", "..", "..", "PolyPilot", "Services", "CopilotService.cs")); + "..", "..", "..", "..", "PolyPilot.Core", "Services", "CopilotService.cs")); // Find the soft steer block (between "[STEER]" debug log and hard steer comment) var softSteerStart = source.IndexOf("[STEER] '{sessionName}' soft steer"); @@ -366,7 +366,7 @@ public async Task SteerSession_SoftSteerPath_ContainsConnectionErrorFallback() System.IO.Path.Combine( System.IO.Path.GetDirectoryName(typeof(CopilotService).Assembly.Location)! .Replace("PolyPilot.Tests", "PolyPilot"), - "..", "..", "..", "..", "PolyPilot", "Services", "CopilotService.cs")); + "..", "..", "..", "..", "PolyPilot.Core", "Services", "CopilotService.cs")); var softSteerStart = source.IndexOf("[STEER] '{sessionName}' soft steer"); Assert.True(softSteerStart >= 0, "Soft steer debug log not found"); diff --git a/PolyPilot.slnx b/PolyPilot.slnx index 49f6f1b63e..ed668e0f92 100644 --- a/PolyPilot.slnx +++ b/PolyPilot.slnx @@ -2,6 +2,7 @@ + diff --git a/PolyPilot/PolyPilot.csproj b/PolyPilot/PolyPilot.csproj index 38bbdd976e..f63e6615cb 100644 --- a/PolyPilot/PolyPilot.csproj +++ b/PolyPilot/PolyPilot.csproj @@ -68,6 +68,7 @@ + diff --git a/docs/multi-agent-orchestration.md b/docs/multi-agent-orchestration.md index 5d516c86f5..e34c7c89cc 100644 --- a/docs/multi-agent-orchestration.md +++ b/docs/multi-agent-orchestration.md @@ -10,13 +10,13 @@ PolyPilot's multi-agent system lets you create a **team of AI sessions** that wo | File | Purpose | |------|---------| -| `PolyPilot/Services/CopilotService.Organization.cs` | Orchestration engine (dispatch, reflection loop, reconciliation, group deletion) | -| `PolyPilot/Models/SessionOrganization.cs` | `SessionGroup`, `SessionMeta`, `MultiAgentMode`, `MultiAgentRole` | -| `PolyPilot/Models/ReflectionCycle.cs` | Reflection state, stall detection, sentinel parsing, evaluator prompts | -| `PolyPilot/Models/ModelCapabilities.cs` | `GroupPreset`, `UserPresets` (three-tier merge), built-in presets | -| `PolyPilot/Models/SquadDiscovery.cs` | Squad directory parser (`.squad/` → `GroupPreset`) | -| `PolyPilot/Models/SquadWriter.cs` | Squad directory writer (`GroupPreset` → `.squad/`) | -| `PolyPilot/Services/CopilotService.Events.cs` | TCS completion (IsProcessing → TrySetResult ordering) | +| `PolyPilot.Core/Services/CopilotService.Organization.cs` | Orchestration engine (dispatch, reflection loop, reconciliation, group deletion) | +| `PolyPilot.Core/Models/SessionOrganization.cs` | `SessionGroup`, `SessionMeta`, `MultiAgentMode`, `MultiAgentRole` | +| `PolyPilot.Core/Models/ReflectionCycle.cs` | Reflection state, stall detection, sentinel parsing, evaluator prompts | +| `PolyPilot.Core/Models/ModelCapabilities.cs` | `GroupPreset`, `UserPresets` (three-tier merge), built-in presets | +| `PolyPilot.Core/Models/SquadDiscovery.cs` | Squad directory parser (`.squad/` → `GroupPreset`) | +| `PolyPilot.Core/Models/SquadWriter.cs` | Squad directory writer (`GroupPreset` → `.squad/`) | +| `PolyPilot.Core/Services/CopilotService.Events.cs` | TCS completion (IsProcessing → TrySetResult ordering) | | `PolyPilot/Components/Layout/SessionSidebar.razor` | Preset picker UI (sectioned: From Repo / Built-in / My Presets) | | `PolyPilot.Tests/MultiAgentRegressionTests.cs` | 37 regression tests covering all known bugs | | `PolyPilot.Tests/SessionOrganizationTests.cs` | 15 grouping stability tests |