Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions PolyPilot.Tests/ConnectionRecoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
38 changes: 36 additions & 2 deletions PolyPilot/Services/CopilotService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2446,16 +2446,50 @@ public async Task<string> 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<Microsoft.Extensions.AI.AIFunction> { 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;
}
Expand Down