diff --git a/PolyPilot.Tests/ConnectionRecoveryTests.cs b/PolyPilot.Tests/ConnectionRecoveryTests.cs index b0a6e1c24c..f741111268 100644 --- a/PolyPilot.Tests/ConnectionRecoveryTests.cs +++ b/PolyPilot.Tests/ConnectionRecoveryTests.cs @@ -264,6 +264,88 @@ public void SendPromptAsync_ReconnectPath_UsesRefreshedClientForCreateSession() "client.CreateSessionAsync (Session not found fallback) must be after client = _client refresh"); } + // ===== Regression: Fresh session after "Session not found" must include MCP servers & skills ===== + // When the JSON-RPC connection is lost and the server-side session has expired, + // SendPromptAsync falls back to creating a fresh session via CreateSessionAsync. + // Previously, the freshConfig was missing McpServers, SkillDirectories, and + // SystemMessage — causing "environment keeps going away" because MCP tools + // disappeared after reconnection. + + [Fact] + public void SendPromptAsync_FreshSessionConfig_IncludesMcpServers() + { + // STRUCTURAL REGRESSION GUARD: The "Session not found" fallback must assign + // McpServers in the freshConfig so MCP tools survive reconnection. + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + + // Anchor on the freshConfig initializer inside the "Session not found" reconnect path + var freshConfigIndex = source.IndexOf("freshConfig = new SessionConfig"); + Assert.True(freshConfigIndex > 0, "Could not find freshConfig in reconnect path"); + + // Extract the config block (generously sized to cover all fields) + var endIndex = Math.Min(freshConfigIndex + 600, source.Length); + var configBlock = source.Substring(freshConfigIndex, endIndex - freshConfigIndex); + Assert.Contains("McpServers = ", configBlock); + } + + [Fact] + 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 freshConfigIndex = source.IndexOf("freshConfig = new SessionConfig"); + Assert.True(freshConfigIndex > 0, "Could not find freshConfig in reconnect path"); + + var endIndex = Math.Min(freshConfigIndex + 600, source.Length); + var configBlock = source.Substring(freshConfigIndex, endIndex - freshConfigIndex); + Assert.Contains("SkillDirectories = ", configBlock); + } + + [Fact] + 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 freshConfigIndex = source.IndexOf("freshConfig = new SessionConfig"); + Assert.True(freshConfigIndex > 0, "Could not find freshConfig in reconnect path"); + + var endIndex = Math.Min(freshConfigIndex + 600, source.Length); + var configBlock = source.Substring(freshConfigIndex, endIndex - freshConfigIndex); + Assert.Contains("SystemMessage = ", configBlock); + Assert.Contains("SystemMessageMode.Append", configBlock); + } + + [Fact] + public void SendPromptAsync_FreshSessionConfig_MatchesCreateSessionFields() + { + // STRUCTURAL REGRESSION GUARD: The freshConfig in the reconnect path must + // set the same critical fields as the original CreateSessionAsync config. + // This prevents "environment keeps going away" after connection loss. + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + + var freshConfigIndex = source.IndexOf("freshConfig = new SessionConfig"); + Assert.True(freshConfigIndex > 0); + + // Extract the full config initializer block + var endIndex = Math.Min(freshConfigIndex + 800, source.Length); + var configBlock = source.Substring(freshConfigIndex, endIndex - freshConfigIndex); + + // All critical SessionConfig property assignments must be present + var requiredAssignments = new[] + { + "Model = ", "WorkingDirectory = ", "McpServers = ", "SkillDirectories = ", + "Tools = ", "SystemMessage = ", "OnPermissionRequest = " + }; + foreach (var assignment in requiredAssignments) + { + Assert.Contains(assignment, configBlock); + } + } + [Fact] public void IsConnectionError_DetectsOrchestratorDispatchError() { diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 1aa4606c99..4fd0ccf4d6 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -2446,16 +2446,50 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis } catch (Exception resumeEx) when (resumeEx.Message.Contains("Session not found", StringComparison.OrdinalIgnoreCase)) { - // Session expired server-side (e.g., codespace restarted). Create a fresh session. + // Session expired server-side (e.g., codespace restarted). Create a fresh session + // with full config (MCP servers, skills, system message) matching CreateSessionAsync. Debug($"Session '{sessionName}' expired on server, creating fresh session..."); OnActivity?.Invoke(sessionName, "🔄 Session expired, creating new session..."); + var freshSettings = _currentSettings ?? ConnectionSettings.Load(); + var freshMcpServers = LoadMcpServers(freshSettings.DisabledMcpServers, freshSettings.DisabledPlugins); + var freshSkillDirs = LoadSkillDirectories(freshSettings.DisabledPlugins); + // Rebuild system message with the same conditional logic as CreateSessionAsync + var freshSystemContent = new StringBuilder(); + var freshDir = state.Info.WorkingDirectory; + if (string.Equals(freshDir, ProjectDir, StringComparison.OrdinalIgnoreCase)) + { + var relaunchCmd = OperatingSystem.IsWindows() + ? $"powershell -ExecutionPolicy Bypass -File \"{Path.Combine(ProjectDir, "relaunch.ps1")}\"" + : $"bash {Path.Combine(ProjectDir, "relaunch.sh")}"; + freshSystemContent.AppendLine($@" +CRITICAL BUILD INSTRUCTION: You are running inside the PolyPilot MAUI application. +When you make ANY code changes to files in {ProjectDir}, you MUST rebuild and relaunch by running: + + {relaunchCmd} + +This script builds the app, launches a new instance, waits for it to start, then kills the old one. +NEVER use 'dotnet build' + 'open' separately. NEVER skip the relaunch after code changes. +ALWAYS run the relaunch script as the final step after making changes to this project. +"); + } var freshConfig = new SessionConfig { Model = reconnectModel ?? DefaultModel, - WorkingDirectory = state.Info.WorkingDirectory, + WorkingDirectory = freshDir, + McpServers = freshMcpServers, + SkillDirectories = freshSkillDirs, Tools = new List { ShowImageTool.CreateFunction() }, + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Append, + Content = freshSystemContent.ToString() + }, OnPermissionRequest = AutoApprovePermissions }; + if (freshMcpServers != null) + Debug($"[RECONNECT] Fresh session config includes {freshMcpServers.Count} MCP server(s)"); + if (freshSkillDirs != null) + Debug($"[RECONNECT] Fresh session config includes {freshSkillDirs.Count} skill dir(s)"); newSession = await client.CreateSessionAsync(freshConfig, cancellationToken); state.Info.SessionId = newSession.SessionId; }