From 47db768cfca165bdfd81262401cada2f561c0d41 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Mon, 9 Mar 2026 12:24:08 -0500 Subject: [PATCH 1/2] fix: restore MCP servers and skills when recreating expired session When a JSON-RPC connection is lost and the server-side session has expired ('Session not found'), SendPromptAsync creates a fresh session. Previously, the freshConfig was missing McpServers and SkillDirectories, causing the session's environment (MCP tools, skills) to disappear after reconnection. Now the reconnect path loads McpServers and SkillDirectories from settings, matching the original CreateSessionAsync behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ConnectionRecoveryTests.cs | 62 ++++++++++++++++++++++ PolyPilot/Services/CopilotService.cs | 12 ++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/PolyPilot.Tests/ConnectionRecoveryTests.cs b/PolyPilot.Tests/ConnectionRecoveryTests.cs index b0a6e1c24c..da2793e715 100644 --- a/PolyPilot.Tests/ConnectionRecoveryTests.cs +++ b/PolyPilot.Tests/ConnectionRecoveryTests.cs @@ -264,6 +264,68 @@ 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 load + // McpServers so MCP tools survive reconnection. + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + + // Find the "Session not found" catch block + var sessionNotFoundIndex = source.IndexOf("Session not found", StringComparison.OrdinalIgnoreCase); + Assert.True(sessionNotFoundIndex > 0, "Could not find 'Session not found' catch block"); + + // The freshConfig must include McpServers + var afterNotFound = source.Substring(sessionNotFoundIndex, 800); + Assert.Contains("McpServers", afterNotFound); + Assert.Contains("LoadMcpServers", afterNotFound); + } + + [Fact] + public void SendPromptAsync_FreshSessionConfig_IncludesSkillDirectories() + { + // STRUCTURAL REGRESSION GUARD: The "Session not found" fallback must load + // SkillDirectories so skill-based tools survive reconnection. + var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); + + var sessionNotFoundIndex = source.IndexOf("Session not found", StringComparison.OrdinalIgnoreCase); + Assert.True(sessionNotFoundIndex > 0, "Could not find 'Session not found' catch block"); + + var afterNotFound = source.Substring(sessionNotFoundIndex, 800); + Assert.Contains("SkillDirectories", afterNotFound); + Assert.Contains("LoadSkillDirectories", afterNotFound); + } + + [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 sessionNotFoundIndex = source.IndexOf("Session not found", StringComparison.OrdinalIgnoreCase); + Assert.True(sessionNotFoundIndex > 0); + + // Extract the freshConfig block (from "Session not found" to well past the CreateSessionAsync call) + var endIndex = Math.Min(sessionNotFoundIndex + 1500, source.Length); + var afterNotFound = source.Substring(sessionNotFoundIndex, endIndex - sessionNotFoundIndex); + + // All critical SessionConfig fields must be present + var requiredFields = new[] { "Model", "WorkingDirectory", "McpServers", "SkillDirectories", "Tools", "OnPermissionRequest" }; + foreach (var field in requiredFields) + { + Assert.Contains(field, afterNotFound); + } + } + [Fact] public void IsConnectionError_DetectsOrchestratorDispatchError() { diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 1aa4606c99..328e126e7e 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -2446,16 +2446,26 @@ 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); var freshConfig = new SessionConfig { Model = reconnectModel ?? DefaultModel, WorkingDirectory = state.Info.WorkingDirectory, + McpServers = freshMcpServers, + SkillDirectories = freshSkillDirs, Tools = new List { ShowImageTool.CreateFunction() }, 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; } From 5602798e2bb2b20ba33fc85a842be4810e822d41 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Mon, 9 Mar 2026 14:26:20 -0500 Subject: [PATCH 2/2] fix: add SystemMessage to freshConfig and strengthen structural tests Address PR review feedback: - Add SystemMessage with relaunch instructions to freshConfig, matching CreateSessionAsync's conditional logic for ProjectDir sessions - Anchor structural tests on 'freshConfig = new SessionConfig' instead of 'Session not found' for reliable window sizing - Assert property assignments ('McpServers = ') not just identifier names - Add dedicated test for SystemMessage presence - Include SystemMessage in MatchesCreateSessionFields required fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ConnectionRecoveryTests.cs | 70 ++++++++++++++-------- PolyPilot/Services/CopilotService.cs | 26 +++++++- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/PolyPilot.Tests/ConnectionRecoveryTests.cs b/PolyPilot.Tests/ConnectionRecoveryTests.cs index da2793e715..f741111268 100644 --- a/PolyPilot.Tests/ConnectionRecoveryTests.cs +++ b/PolyPilot.Tests/ConnectionRecoveryTests.cs @@ -274,33 +274,49 @@ public void SendPromptAsync_ReconnectPath_UsesRefreshedClientForCreateSession() [Fact] public void SendPromptAsync_FreshSessionConfig_IncludesMcpServers() { - // STRUCTURAL REGRESSION GUARD: The "Session not found" fallback must load - // McpServers so MCP tools survive reconnection. + // 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")); - // Find the "Session not found" catch block - var sessionNotFoundIndex = source.IndexOf("Session not found", StringComparison.OrdinalIgnoreCase); - Assert.True(sessionNotFoundIndex > 0, "Could not find 'Session not found' catch block"); + // 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"); - // The freshConfig must include McpServers - var afterNotFound = source.Substring(sessionNotFoundIndex, 800); - Assert.Contains("McpServers", afterNotFound); - Assert.Contains("LoadMcpServers", afterNotFound); + // 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 load - // SkillDirectories so skill-based tools survive reconnection. + // 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 sessionNotFoundIndex = source.IndexOf("Session not found", StringComparison.OrdinalIgnoreCase); - Assert.True(sessionNotFoundIndex > 0, "Could not find 'Session not found' catch block"); + var freshConfigIndex = source.IndexOf("freshConfig = new SessionConfig"); + Assert.True(freshConfigIndex > 0, "Could not find freshConfig in reconnect path"); - var afterNotFound = source.Substring(sessionNotFoundIndex, 800); - Assert.Contains("SkillDirectories", afterNotFound); - Assert.Contains("LoadSkillDirectories", afterNotFound); + 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] @@ -311,18 +327,22 @@ public void SendPromptAsync_FreshSessionConfig_MatchesCreateSessionFields() // This prevents "environment keeps going away" after connection loss. var source = File.ReadAllText(Path.Combine(GetRepoRoot(), "PolyPilot", "Services", "CopilotService.cs")); - var sessionNotFoundIndex = source.IndexOf("Session not found", StringComparison.OrdinalIgnoreCase); - Assert.True(sessionNotFoundIndex > 0); + var freshConfigIndex = source.IndexOf("freshConfig = new SessionConfig"); + Assert.True(freshConfigIndex > 0); - // Extract the freshConfig block (from "Session not found" to well past the CreateSessionAsync call) - var endIndex = Math.Min(sessionNotFoundIndex + 1500, source.Length); - var afterNotFound = source.Substring(sessionNotFoundIndex, endIndex - sessionNotFoundIndex); + // Extract the full config initializer block + var endIndex = Math.Min(freshConfigIndex + 800, source.Length); + var configBlock = source.Substring(freshConfigIndex, endIndex - freshConfigIndex); - // All critical SessionConfig fields must be present - var requiredFields = new[] { "Model", "WorkingDirectory", "McpServers", "SkillDirectories", "Tools", "OnPermissionRequest" }; - foreach (var field in requiredFields) + // 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(field, afterNotFound); + Assert.Contains(assignment, configBlock); } } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 328e126e7e..4fd0ccf4d6 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -2453,13 +2453,37 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis 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)