From 5fdc345875201d3a8e9458d9ec58d782b7cc7c20 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 20:23:30 -0600 Subject: [PATCH 01/19] Fix: Model switching not working in remote mode (mobile/devtunnel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChangeModelAsync had no remote mode path — it tried to use the local CopilotClient which is null on mobile, silently failing. The bridge protocol had no message type for model changes. Added the full bridge plumbing: - BridgeMessageTypes.ChangeModel constant - ChangeModelPayload with SessionName + NewModel - IWsBridgeClient.ChangeModelAsync interface method - WsBridgeClient.ChangeModelAsync implementation - WsBridgeServer handler dispatching to CopilotService.ChangeModelAsync - CopilotService.ChangeModelAsync remote mode delegation with optimistic local state update Tests: - ChangeModel message type constant - Round-trip serialization - Payload defaults - CamelCase serialization - AllClientToServerTypes uniqueness updated Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/RemoteModeTests.cs | 43 +++++++++++++++++++++++++++ PolyPilot.Tests/TestStubs.cs | 10 +++++++ PolyPilot/Models/BridgeMessages.cs | 7 +++++ PolyPilot/Services/CopilotService.cs | 13 ++++++++ PolyPilot/Services/IWsBridgeClient.cs | 1 + PolyPilot/Services/WsBridgeClient.cs | 4 +++ PolyPilot/Services/WsBridgeServer.cs | 9 ++++++ 7 files changed, 87 insertions(+) diff --git a/PolyPilot.Tests/RemoteModeTests.cs b/PolyPilot.Tests/RemoteModeTests.cs index 88f80b6599..be648a27a1 100644 --- a/PolyPilot.Tests/RemoteModeTests.cs +++ b/PolyPilot.Tests/RemoteModeTests.cs @@ -46,6 +46,7 @@ public void AllClientToServerTypes_AreUnique() BridgeMessageTypes.QueueMessage, BridgeMessageTypes.CloseSession, BridgeMessageTypes.AbortSession, + BridgeMessageTypes.ChangeModel, BridgeMessageTypes.OrganizationCommand, BridgeMessageTypes.ListDirectories, }; @@ -758,4 +759,46 @@ public void HistorySync_ShouldNotOverwrite_WhenCacheIsStale() Assert.False(shouldSync, "Should NOT sync when cache has fewer messages than local history"); } + + [Fact] + public void ChangeModel_MessageType_IsCorrect() + { + Assert.Equal("change_model", BridgeMessageTypes.ChangeModel); + } + + [Fact] + public void ChangeModel_RoundTrip() + { + var payload = new ChangeModelPayload { SessionName = "my-session", NewModel = "claude-sonnet-4-5" }; + var msg = BridgeMessage.Create(BridgeMessageTypes.ChangeModel, payload); + var json = msg.Serialize(); + var restored = BridgeMessage.Deserialize(json); + + Assert.NotNull(restored); + Assert.Equal(BridgeMessageTypes.ChangeModel, restored!.Type); + + var restoredPayload = restored.GetPayload(); + Assert.NotNull(restoredPayload); + Assert.Equal("my-session", restoredPayload!.SessionName); + Assert.Equal("claude-sonnet-4-5", restoredPayload.NewModel); + } + + [Fact] + public void ChangeModelPayload_DefaultValues() + { + var payload = new ChangeModelPayload(); + Assert.Equal("", payload.SessionName); + Assert.Equal("", payload.NewModel); + } + + [Fact] + public void ChangeModel_Serialization_CamelCase() + { + var payload = new ChangeModelPayload { SessionName = "test", NewModel = "gpt-4-1" }; + var msg = BridgeMessage.Create(BridgeMessageTypes.ChangeModel, payload); + var json = msg.Serialize(); + + Assert.Contains("\"sessionName\"", json); + Assert.Contains("\"newModel\"", json); + } } diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index 865dde1d07..1218d3d03b 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -102,6 +102,16 @@ public Task SwitchSessionAsync(string name, CancellationToken ct = default) public Task ResumeSessionAsync(string sessionId, string? displayName = null, CancellationToken ct = default) => Task.CompletedTask; public Task CloseSessionAsync(string name, CancellationToken ct = default) => Task.CompletedTask; public Task AbortSessionAsync(string sessionName, CancellationToken ct = default) => Task.CompletedTask; + public string? LastChangedModelSession { get; private set; } + public string? LastChangedModel { get; private set; } + public int ChangeModelCallCount { get; private set; } + public Task ChangeModelAsync(string sessionName, string newModel, CancellationToken ct = default) + { + LastChangedModelSession = sessionName; + LastChangedModel = newModel; + ChangeModelCallCount++; + return Task.CompletedTask; + } public Task SendOrganizationCommandAsync(OrganizationCommandPayload payload, CancellationToken ct = default) => Task.CompletedTask; public Task ListDirectoriesAsync(string? path = null, CancellationToken ct = default) => Task.FromResult(new DirectoriesListPayload()); diff --git a/PolyPilot/Models/BridgeMessages.cs b/PolyPilot/Models/BridgeMessages.cs index 5766ceade7..63ab9e0751 100644 --- a/PolyPilot/Models/BridgeMessages.cs +++ b/PolyPilot/Models/BridgeMessages.cs @@ -81,6 +81,7 @@ public static class BridgeMessageTypes public const string AbortSession = "abort_session"; public const string OrganizationCommand = "organization_command"; public const string ListDirectories = "list_directories"; + public const string ChangeModel = "change_model"; // Server → Client (response) public const string DirectoriesList = "directories_list"; @@ -242,6 +243,12 @@ public class ResumeSessionPayload public string? DisplayName { get; set; } } +public class ChangeModelPayload +{ + public string SessionName { get; set; } = ""; + public string NewModel { get; set; } = ""; +} + // --- Organization bridge payloads --- public class OrganizationCommandPayload diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 3fb1f352a9..715de9c88b 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1359,6 +1359,19 @@ ALWAYS run the relaunch script as the final step after making changes to this pr /// public async Task ChangeModelAsync(string sessionName, string newModel, CancellationToken cancellationToken = default) { + if (IsRemoteMode) + { + if (!_bridgeClient.IsConnected) return false; + var remoteModel = Models.ModelHelper.NormalizeToSlug(newModel); + if (string.IsNullOrEmpty(remoteModel)) return false; + await _bridgeClient.ChangeModelAsync(sessionName, remoteModel, cancellationToken); + // Update local state optimistically + if (_sessions.TryGetValue(sessionName, out var remoteState)) + remoteState.Info.Model = remoteModel; + OnStateChanged?.Invoke(); + return true; + } + if (!_sessions.TryGetValue(sessionName, out var state)) return false; if (state.Info.IsProcessing) return false; if (string.IsNullOrEmpty(state.Info.SessionId)) return false; diff --git a/PolyPilot/Services/IWsBridgeClient.cs b/PolyPilot/Services/IWsBridgeClient.cs index f80e82f345..57e415c38f 100644 --- a/PolyPilot/Services/IWsBridgeClient.cs +++ b/PolyPilot/Services/IWsBridgeClient.cs @@ -43,6 +43,7 @@ public interface IWsBridgeClient Task ResumeSessionAsync(string sessionId, string? displayName = null, CancellationToken ct = default); Task CloseSessionAsync(string name, CancellationToken ct = default); Task AbortSessionAsync(string sessionName, CancellationToken ct = default); + Task ChangeModelAsync(string sessionName, string newModel, CancellationToken ct = default); Task SendOrganizationCommandAsync(OrganizationCommandPayload payload, CancellationToken ct = default); Task ListDirectoriesAsync(string? path = null, CancellationToken ct = default); } diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index 9e2c6cbdc2..fa165ee007 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -190,6 +190,10 @@ public async Task AbortSessionAsync(string sessionName, CancellationToken ct = d await SendAsync(BridgeMessage.Create(BridgeMessageTypes.AbortSession, new SessionNamePayload { SessionName = sessionName }), ct); + public async Task ChangeModelAsync(string sessionName, string newModel, CancellationToken ct = default) => + await SendAsync(BridgeMessage.Create(BridgeMessageTypes.ChangeModel, + new ChangeModelPayload { SessionName = sessionName, NewModel = newModel }), ct); + public async Task SendOrganizationCommandAsync(OrganizationCommandPayload cmd, CancellationToken ct = default) => await SendAsync(BridgeMessage.Create(BridgeMessageTypes.OrganizationCommand, cmd), ct); diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index b9ecd732d5..8691d9c860 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -444,6 +444,15 @@ await SendToClientAsync(clientId, ws, } break; + case BridgeMessageTypes.ChangeModel: + var changeModelReq = msg.GetPayload(); + if (changeModelReq != null && !string.IsNullOrWhiteSpace(changeModelReq.SessionName)) + { + Console.WriteLine($"[WsBridge] Client changing model for '{changeModelReq.SessionName}' to '{changeModelReq.NewModel}'"); + await _copilot.ChangeModelAsync(changeModelReq.SessionName, changeModelReq.NewModel); + } + break; + case BridgeMessageTypes.OrganizationCommand: var orgCmd = msg.GetPayload(); if (orgCmd != null) From 658c2c7b35ff497182b6ca00f75f67db6b529aa0 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 20:29:34 -0600 Subject: [PATCH 02/19] Add WsBridge integration tests with real localhost WebSocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up a real WsBridgeServer on localhost, connect a real WsBridgeClient, and verify end-to-end message flows — simulating the mobile → devtunnel → desktop path without needing a real tunnel. Tests cover: connect, create session, change model, abort, close, and session list synchronization. Also fix ChangeModelAsync for demo mode — it was hitting the SDK path which requires a real CopilotClient. Demo mode now just updates the model label locally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/PolyPilot.Tests.csproj | 1 + PolyPilot.Tests/WsBridgeIntegrationTests.cs | 160 ++++++++++++++++++++ PolyPilot/Services/CopilotService.cs | 11 +- 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 PolyPilot.Tests/WsBridgeIntegrationTests.cs diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index f5d432a49c..78a3b118c5 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -48,6 +48,7 @@ + diff --git a/PolyPilot.Tests/WsBridgeIntegrationTests.cs b/PolyPilot.Tests/WsBridgeIntegrationTests.cs new file mode 100644 index 0000000000..2dc1f82205 --- /dev/null +++ b/PolyPilot.Tests/WsBridgeIntegrationTests.cs @@ -0,0 +1,160 @@ +using System.Net.WebSockets; +using Microsoft.Extensions.DependencyInjection; +using PolyPilot.Models; +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +/// +/// Integration tests that stand up a real WsBridgeServer on localhost, +/// connect a real WsBridgeClient, and verify end-to-end bridge message flows. +/// This simulates the mobile → devtunnel → desktop path without needing +/// a real devtunnel or device. +/// +public class WsBridgeIntegrationTests : IDisposable +{ + private readonly WsBridgeServer _server; + private readonly CopilotService _copilot; + private readonly int _port; + + public WsBridgeIntegrationTests() + { + // Use a random high port to avoid conflicts with parallel tests + _port = Random.Shared.Next(19000, 19999); + _server = new WsBridgeServer(); + + _copilot = new CopilotService( + new StubChatDatabase(), + new StubServerManager(), + new StubWsBridgeClient(), + new RepoManager(), + new ServiceCollection().BuildServiceProvider(), + new StubDemoService()); + + _server.SetCopilotService(_copilot); + _server.Start(_port, 0); + } + + public void Dispose() + { + _server.Stop(); + _server.Dispose(); + } + + private async Task ConnectClientAsync(CancellationToken ct = default) + { + var client = new WsBridgeClient(); + await client.ConnectAsync($"ws://localhost:{_port}/", null, ct); + return client; + } + + private async Task InitDemoMode() + { + await _copilot.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + } + + [Fact] + public async Task Client_CanConnect_ToLocalServer() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + Assert.True(client.IsConnected); + client.Stop(); + } + + [Fact] + public async Task Client_CreateSession_AppearsOnServer() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.CreateSessionAsync("integration-test", "gpt-4.1", null, cts.Token); + // Give server time to process + await Task.Delay(500, cts.Token); + + var session = _copilot.GetSession("integration-test"); + Assert.NotNull(session); + client.Stop(); + } + + [Fact] + public async Task Client_ChangeModel_UpdatesServerSession() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + // Create session on server directly + await _copilot.CreateSessionAsync("model-test", "gpt-4.1"); + var session = _copilot.GetSession("model-test"); + Assert.NotNull(session); + Assert.Equal("gpt-4.1", session!.Model); + + // Connect client and send ChangeModel + var client = await ConnectClientAsync(cts.Token); + await client.ChangeModelAsync("model-test", "claude-sonnet-4-5", cts.Token); + await Task.Delay(500, cts.Token); + + // Verify model changed on server + session = _copilot.GetSession("model-test"); + Assert.NotNull(session); + Assert.Equal("claude-sonnet-4-5", session!.Model); + client.Stop(); + } + + [Fact] + public async Task Client_AbortSession_ReachesServer() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await _copilot.CreateSessionAsync("abort-test", "gpt-4.1"); + var client = await ConnectClientAsync(cts.Token); + + // AbortSession should not throw + await client.AbortSessionAsync("abort-test", cts.Token); + await Task.Delay(300, cts.Token); + + // Session should still exist (abort stops processing, doesn't delete) + var session = _copilot.GetSession("abort-test"); + Assert.NotNull(session); + client.Stop(); + } + + [Fact] + public async Task Client_CloseSession_RemovesFromServer() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await _copilot.CreateSessionAsync("close-test", "gpt-4.1"); + var client = await ConnectClientAsync(cts.Token); + + await client.CloseSessionAsync("close-test", cts.Token); + await Task.Delay(500, cts.Token); + + var session = _copilot.GetSession("close-test"); + Assert.Null(session); + client.Stop(); + } + + [Fact] + public async Task Client_RequestSessions_ReceivesList() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await _copilot.CreateSessionAsync("list-test-1", "gpt-4.1"); + await _copilot.CreateSessionAsync("list-test-2", "gpt-4.1"); + + var client = await ConnectClientAsync(cts.Token); + + // Client receives session list on connect; wait for it + await Task.Delay(500, cts.Token); + + Assert.True(client.Sessions.Count >= 2); + Assert.Contains(client.Sessions, s => s.Name == "list-test-1"); + Assert.Contains(client.Sessions, s => s.Name == "list-test-2"); + client.Stop(); + } +} diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 715de9c88b..e09b108f27 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1374,7 +1374,6 @@ public async Task ChangeModelAsync(string sessionName, string newModel, Ca if (!_sessions.TryGetValue(sessionName, out var state)) return false; if (state.Info.IsProcessing) return false; - if (string.IsNullOrEmpty(state.Info.SessionId)) return false; var normalizedModel = Models.ModelHelper.NormalizeToSlug(newModel); if (string.IsNullOrEmpty(normalizedModel)) return false; @@ -1382,6 +1381,16 @@ public async Task ChangeModelAsync(string sessionName, string newModel, Ca // Already on this model — no-op if (state.Info.Model == normalizedModel) return true; + // Demo mode: just update the model label (no SDK session to resume) + if (IsDemoMode) + { + state.Info.Model = normalizedModel; + OnStateChanged?.Invoke(); + return true; + } + + if (string.IsNullOrEmpty(state.Info.SessionId)) return false; + Debug($"Switching model for '{sessionName}': {state.Info.Model} → {normalizedModel}"); try From 233228e40bb3f4c03e2c5c5f6f5d8e62dbbbad42 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 20:44:19 -0600 Subject: [PATCH 03/19] Add comprehensive WsBridge integration tests covering all bridge operations - 31 integration tests using real WsBridgeServer/Client on localhost - Covers: connection, session CRUD, messaging, content deltas, queuing, model switching, session switching, directory listing, security (path traversal), and all organization commands (create/rename/delete group, pin/unpin, move, toggle collapsed, sort mode, broadcast) - Fix demo mode CreateSessionAsync missing SessionMeta in Organization - Fix StubDemoService to fire content/turn events for realistic testing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/TestStubs.cs | 14 +- PolyPilot.Tests/WsBridgeIntegrationTests.cs | 500 ++++++++++++++++++-- PolyPilot/Services/CopilotService.cs | 2 + 3 files changed, 481 insertions(+), 35 deletions(-) diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index 1218d3d03b..fec13f0dbb 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -145,7 +145,17 @@ public bool TryGetSession(string name, out AgentSessionInfo? info) public void SetActiveSession(string name) { if (_sessions.ContainsKey(name)) ActiveSessionName = name; } - public Task SimulateResponseAsync(string sessionName, string prompt, SynchronizationContext? syncContext = null, CancellationToken ct = default) - => Task.CompletedTask; + public async Task SimulateResponseAsync(string sessionName, string prompt, SynchronizationContext? syncContext = null, CancellationToken ct = default) + { + await Task.Delay(10, ct); + OnContentReceived?.Invoke(sessionName, "Demo response"); + if (_sessions.TryGetValue(sessionName, out var info)) + { + info.History.Add(ChatMessage.AssistantMessage("Demo response")); + info.IsProcessing = false; + } + OnTurnEnd?.Invoke(sessionName); + OnStateChanged?.Invoke(); + } } #pragma warning restore CS0067 diff --git a/PolyPilot.Tests/WsBridgeIntegrationTests.cs b/PolyPilot.Tests/WsBridgeIntegrationTests.cs index 2dc1f82205..c1d8ae481c 100644 --- a/PolyPilot.Tests/WsBridgeIntegrationTests.cs +++ b/PolyPilot.Tests/WsBridgeIntegrationTests.cs @@ -16,11 +16,11 @@ public class WsBridgeIntegrationTests : IDisposable private readonly WsBridgeServer _server; private readonly CopilotService _copilot; private readonly int _port; + private static int _portCounter = 19100; public WsBridgeIntegrationTests() { - // Use a random high port to avoid conflicts with parallel tests - _port = Random.Shared.Next(19000, 19999); + _port = Interlocked.Increment(ref _portCounter); _server = new WsBridgeServer(); _copilot = new CopilotService( @@ -53,8 +53,10 @@ private async Task InitDemoMode() await _copilot.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); } + // ========== CONNECTION ========== + [Fact] - public async Task Client_CanConnect_ToLocalServer() + public async Task Connect_ClientReceivesConnectedState() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var client = await ConnectClientAsync(cts.Token); @@ -63,98 +65,530 @@ public async Task Client_CanConnect_ToLocalServer() } [Fact] - public async Task Client_CreateSession_AppearsOnServer() + public async Task Connect_ClientReceivesSessionList_OnConnect() { await InitDemoMode(); + await _copilot.CreateSessionAsync("pre-existing", "gpt-4.1"); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var client = await ConnectClientAsync(cts.Token); + await Task.Delay(500, cts.Token); + + Assert.Contains(client.Sessions, s => s.Name == "pre-existing"); + client.Stop(); + } + + [Fact] + public async Task Connect_ClientReceivesOrganizationState_OnConnect() + { + await InitDemoMode(); + _copilot.CreateGroup("TestGroup"); - await client.CreateSessionAsync("integration-test", "gpt-4.1", null, cts.Token); - // Give server time to process + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var orgReceived = new TaskCompletionSource(); + var client = new WsBridgeClient(); + client.OnOrganizationStateReceived += org => orgReceived.TrySetResult(org); + await client.ConnectAsync($"ws://localhost:{_port}/", null, cts.Token); + + var org = await orgReceived.Task.WaitAsync(cts.Token); + Assert.Contains(org.Groups, g => g.Name == "TestGroup"); + client.Stop(); + } + + [Fact] + public async Task Connect_ClientReceivesHistory_ForExistingSessions() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("history-test", "gpt-4.1"); + await _copilot.SendPromptAsync("history-test", "Hello from test"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); await Task.Delay(500, cts.Token); - var session = _copilot.GetSession("integration-test"); - Assert.NotNull(session); + Assert.True(client.SessionHistories.ContainsKey("history-test")); + Assert.True(client.SessionHistories["history-test"].Count > 0); client.Stop(); } + // ========== SESSION LIFECYCLE ========== + [Fact] - public async Task Client_ChangeModel_UpdatesServerSession() + public async Task CreateSession_AppearsOnServer() { await InitDemoMode(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); - // Create session on server directly - await _copilot.CreateSessionAsync("model-test", "gpt-4.1"); - var session = _copilot.GetSession("model-test"); - Assert.NotNull(session); - Assert.Equal("gpt-4.1", session!.Model); + await client.CreateSessionAsync("new-session", "gpt-4.1", null, cts.Token); + await Task.Delay(500, cts.Token); + + Assert.NotNull(_copilot.GetSession("new-session")); + client.Stop(); + } - // Connect client and send ChangeModel + [Fact] + public async Task CreateSession_WithModel_SetsModelOnServer() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var client = await ConnectClientAsync(cts.Token); - await client.ChangeModelAsync("model-test", "claude-sonnet-4-5", cts.Token); + + await client.CreateSessionAsync("model-create", "claude-sonnet-4-5", null, cts.Token); await Task.Delay(500, cts.Token); - // Verify model changed on server - session = _copilot.GetSession("model-test"); + var session = _copilot.GetSession("model-create"); Assert.NotNull(session); Assert.Equal("claude-sonnet-4-5", session!.Model); client.Stop(); } [Fact] - public async Task Client_AbortSession_ReachesServer() + public async Task CreateSession_BroadcastsUpdatedList_ToClient() { await InitDemoMode(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + await client.CreateSessionAsync("broadcast-test", "gpt-4.1", null, cts.Token); + await Task.Delay(500, cts.Token); + + Assert.Contains(client.Sessions, s => s.Name == "broadcast-test"); + client.Stop(); + } + + [Fact] + public async Task CloseSession_RemovesFromServer() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("close-me", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.CloseSessionAsync("close-me", cts.Token); + await Task.Delay(500, cts.Token); + + Assert.Null(_copilot.GetSession("close-me")); + client.Stop(); + } + + [Fact] + public async Task CloseSession_UpdatesClientSessionList() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("close-broadcast", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + await Task.Delay(300, cts.Token); + Assert.Contains(client.Sessions, s => s.Name == "close-broadcast"); + + await client.CloseSessionAsync("close-broadcast", cts.Token); + await Task.Delay(500, cts.Token); + + Assert.DoesNotContain(client.Sessions, s => s.Name == "close-broadcast"); + client.Stop(); + } + + [Fact] + public async Task AbortSession_DoesNotRemoveSession() + { + await InitDemoMode(); await _copilot.CreateSessionAsync("abort-test", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var client = await ConnectClientAsync(cts.Token); - // AbortSession should not throw await client.AbortSessionAsync("abort-test", cts.Token); await Task.Delay(300, cts.Token); - // Session should still exist (abort stops processing, doesn't delete) - var session = _copilot.GetSession("abort-test"); + Assert.NotNull(_copilot.GetSession("abort-test")); + client.Stop(); + } + + // ========== MODEL SWITCHING ========== + + [Fact] + public async Task ChangeModel_UpdatesServerSession() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("model-switch", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.ChangeModelAsync("model-switch", "claude-sonnet-4-5", cts.Token); + await Task.Delay(500, cts.Token); + + Assert.Equal("claude-sonnet-4-5", _copilot.GetSession("model-switch")!.Model); + client.Stop(); + } + + [Fact] + public async Task ChangeModel_NonExistentSession_DoesNotThrow() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + // Should not throw — server should handle gracefully + await client.ChangeModelAsync("no-such-session", "gpt-4.1", cts.Token); + await Task.Delay(300, cts.Token); + client.Stop(); + } + + // ========== MESSAGING ========== + + [Fact] + public async Task SendMessage_AddsUserMessageToServerHistory() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("msg-test", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.SendMessageAsync("msg-test", "Hello from mobile", cts.Token); + await Task.Delay(500, cts.Token); + + var session = _copilot.GetSession("msg-test"); Assert.NotNull(session); + Assert.Contains(session!.History, m => m.Content?.Contains("Hello from mobile") == true); + client.Stop(); + } + + [Fact] + public async Task SendMessage_TriggersContentDelta_OnClient() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("delta-test", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var contentReceived = new TaskCompletionSource(); + var client = new WsBridgeClient(); + client.OnContentReceived += (session, content) => + { + if (session == "delta-test") contentReceived.TrySetResult(content); + }; + await client.ConnectAsync($"ws://localhost:{_port}/", null, cts.Token); + + await client.SendMessageAsync("delta-test", "Tell me a joke", cts.Token); + + // Demo mode sends a simulated response with content deltas + var content = await contentReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(string.IsNullOrEmpty(content)); + client.Stop(); + } + + [Fact] + public async Task QueueMessage_EnqueuesOnServer() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("queue-test", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.QueueMessageAsync("queue-test", "queued msg", cts.Token); + await Task.Delay(300, cts.Token); + + var session = _copilot.GetSession("queue-test"); + Assert.NotNull(session); + Assert.Contains(session!.MessageQueue, m => m.Contains("queued msg")); + client.Stop(); + } + + // ========== SESSION SWITCHING ========== + + [Fact] + public async Task SwitchSession_SendsHistoryToClient() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("switch-a", "gpt-4.1"); + await _copilot.SendPromptAsync("switch-a", "Message in A"); + await _copilot.CreateSessionAsync("switch-b", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.SwitchSessionAsync("switch-a", cts.Token); + await Task.Delay(500, cts.Token); + + Assert.True(client.SessionHistories.ContainsKey("switch-a")); + client.Stop(); + } + + // ========== DIRECTORY LISTING ========== + + [Fact] + public async Task ListDirectories_ReturnsEntries() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var result = await client.ListDirectoriesAsync(homePath, cts.Token); + + Assert.NotNull(result); + Assert.Equal(homePath, result.Path); + Assert.Null(result.Error); + Assert.True(result.Directories?.Count > 0); + client.Stop(); + } + + [Fact] + public async Task ListDirectories_InvalidPath_ReturnsError() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + var result = await client.ListDirectoriesAsync("/nonexistent/path/12345", cts.Token); + + Assert.NotNull(result); + Assert.NotNull(result.Error); + client.Stop(); + } + + [Fact] + public async Task ListDirectories_PathTraversal_ReturnsError() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + var result = await client.ListDirectoriesAsync("/tmp/../etc", cts.Token); + + Assert.NotNull(result); + Assert.Equal("Invalid path", result.Error); + client.Stop(); + } + + // ========== ORGANIZATION COMMANDS ========== + + [Fact] + public async Task Organization_CreateGroup_AppearsOnServer() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "create_group", Name = "Mobile Group" }, cts.Token); + await Task.Delay(500, cts.Token); + + Assert.Contains(_copilot.Organization.Groups, g => g.Name == "Mobile Group"); + client.Stop(); + } + + [Fact] + public async Task Organization_PinSession_PinsOnServer() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("pin-me", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "pin", SessionName = "pin-me" }, cts.Token); + await Task.Delay(500, cts.Token); + + var meta = _copilot.Organization.Sessions.FirstOrDefault(s => s.SessionName == "pin-me"); + Assert.NotNull(meta); + Assert.True(meta!.IsPinned); + client.Stop(); + } + + [Fact] + public async Task Organization_UnpinSession_UnpinsOnServer() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("unpin-me", "gpt-4.1"); + _copilot.PinSession("unpin-me", true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "unpin", SessionName = "unpin-me" }, cts.Token); + await Task.Delay(500, cts.Token); + + var meta = _copilot.Organization.Sessions.FirstOrDefault(s => s.SessionName == "unpin-me"); + Assert.NotNull(meta); + Assert.False(meta!.IsPinned); + client.Stop(); + } + + [Fact] + public async Task Organization_MoveSession_MovesOnServer() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("move-me", "gpt-4.1"); + var group = _copilot.CreateGroup("Target"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "move", SessionName = "move-me", GroupId = group.Id }, cts.Token); + await Task.Delay(500, cts.Token); + + var meta = _copilot.Organization.Sessions.FirstOrDefault(s => s.SessionName == "move-me"); + Assert.NotNull(meta); + Assert.Equal(group.Id, meta!.GroupId); client.Stop(); } [Fact] - public async Task Client_CloseSession_RemovesFromServer() + public async Task Organization_RenameGroup_RenamesOnServer() { await InitDemoMode(); + var group = _copilot.CreateGroup("OldName"); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "rename_group", GroupId = group.Id, Name = "NewName" }, cts.Token); + await Task.Delay(500, cts.Token); - await _copilot.CreateSessionAsync("close-test", "gpt-4.1"); + var renamed = _copilot.Organization.Groups.FirstOrDefault(g => g.Id == group.Id); + Assert.NotNull(renamed); + Assert.Equal("NewName", renamed!.Name); + client.Stop(); + } + + [Fact] + public async Task Organization_DeleteGroup_RemovesFromServer() + { + await InitDemoMode(); + var group = _copilot.CreateGroup("DeleteMe"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var client = await ConnectClientAsync(cts.Token); - await client.CloseSessionAsync("close-test", cts.Token); + await client.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "delete_group", GroupId = group.Id }, cts.Token); await Task.Delay(500, cts.Token); - var session = _copilot.GetSession("close-test"); - Assert.Null(session); + Assert.DoesNotContain(_copilot.Organization.Groups, g => g.Id == group.Id); client.Stop(); } [Fact] - public async Task Client_RequestSessions_ReceivesList() + public async Task Organization_ToggleCollapsed_TogglesOnServer() { await InitDemoMode(); + var group = _copilot.CreateGroup("Collapsible"); + Assert.False(group.IsCollapsed); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); - await _copilot.CreateSessionAsync("list-test-1", "gpt-4.1"); - await _copilot.CreateSessionAsync("list-test-2", "gpt-4.1"); + await client.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "toggle_collapsed", GroupId = group.Id }, cts.Token); + await Task.Delay(500, cts.Token); + var updated = _copilot.Organization.Groups.FirstOrDefault(g => g.Id == group.Id); + Assert.NotNull(updated); + Assert.True(updated!.IsCollapsed); + client.Stop(); + } + + [Fact] + public async Task Organization_SetSortMode_UpdatesOnServer() + { + await InitDemoMode(); + Assert.Equal(SessionSortMode.LastActive, _copilot.Organization.SortMode); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var client = await ConnectClientAsync(cts.Token); - // Client receives session list on connect; wait for it + await client.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "set_sort", SortMode = "Alphabetical" }, cts.Token); + await Task.Delay(500, cts.Token); + + Assert.Equal(SessionSortMode.Alphabetical, _copilot.Organization.SortMode); + client.Stop(); + } + + [Fact] + public async Task Organization_BroadcastsStateBack_ToClient() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var orgUpdated = new TaskCompletionSource(); + var client = new WsBridgeClient(); + var callCount = 0; + client.OnOrganizationStateReceived += org => + { + callCount++; + // Skip the initial state sent on connect + if (callCount > 1) orgUpdated.TrySetResult(org); + }; + await client.ConnectAsync($"ws://localhost:{_port}/", null, cts.Token); + await Task.Delay(300, cts.Token); + + await client.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "create_group", Name = "BroadcastGroup" }, cts.Token); + + var org = await orgUpdated.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Contains(org.Groups, g => g.Name == "BroadcastGroup"); + client.Stop(); + } + + // ========== MULTIPLE SESSIONS ========== + + [Fact] + public async Task RequestSessions_ReturnsAllActiveSessions() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("multi-1", "gpt-4.1"); + await _copilot.CreateSessionAsync("multi-2", "claude-sonnet-4-5"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); await Task.Delay(500, cts.Token); Assert.True(client.Sessions.Count >= 2); - Assert.Contains(client.Sessions, s => s.Name == "list-test-1"); - Assert.Contains(client.Sessions, s => s.Name == "list-test-2"); + Assert.Contains(client.Sessions, s => s.Name == "multi-1"); + Assert.Contains(client.Sessions, s => s.Name == "multi-2"); + client.Stop(); + } + + [Fact] + public async Task SessionSummary_ContainsModelInfo() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("model-info", "claude-sonnet-4-5"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + await Task.Delay(500, cts.Token); + + var summary = client.Sessions.FirstOrDefault(s => s.Name == "model-info"); + Assert.NotNull(summary); + Assert.Equal("claude-sonnet-4-5", summary!.Model); + client.Stop(); + } + + // ========== SECURITY ========== + + [Fact] + public async Task CreateSession_WithPathTraversal_IsRejected() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.CreateSessionAsync("traversal-test", "gpt-4.1", "/tmp/../etc", cts.Token); + await Task.Delay(500, cts.Token); + + // Session should not be created with path traversal + Assert.Null(_copilot.GetSession("traversal-test")); client.Stop(); } } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index e09b108f27..0d3d5fc4d5 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1212,6 +1212,8 @@ public async Task CreateSessionAsync(string name, string? mode var demoState = new SessionState { Session = null!, Info = demoInfo }; _sessions[name] = demoState; _activeSessionName ??= name; + if (!Organization.Sessions.Any(m => m.SessionName == name)) + Organization.Sessions.Add(new SessionMeta { SessionName = name, GroupId = SessionGroup.DefaultId }); OnStateChanged?.Invoke(); return demoInfo; } From 13cb3e1e5c078617c35cfa8f559de66df607cbeb Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 21:19:02 -0600 Subject: [PATCH 04/19] Fix 5 production bugs found by multi-model review Bug fixes: - AbortSessionAsync: Add IsDemoMode guard to prevent NRE when Session is null in demo mode - RenameSession: Add full bridge plumbing (message type, payload, client method, server handler, remote mode delegation) so renames propagate over devtunnel instead of silently reverting on next sync - EnqueueMessage: Add IsRemoteMode delegation to forward queued messages to the bridge server - CloseSessionAsync: Change guard from IsConnected to IsRemoteMode for consistency with all other bridge-delegating methods - ListDirectoriesAsync: Replace single _dirListTcs field with ConcurrentDictionary keyed by RequestId to prevent race condition when concurrent directory listing calls corrupt each other's state Tests: 9 new tests covering all 5 fixes including concurrent directory listing, demo mode abort, rename via bridge, and protocol round-trip serialization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/RemoteModeTests.cs | 50 +++++++++++ PolyPilot.Tests/TestStubs.cs | 10 +++ PolyPilot.Tests/WsBridgeIntegrationTests.cs | 93 +++++++++++++++++++++ PolyPilot/Models/BridgeMessages.cs | 9 ++ PolyPilot/Services/CopilotService.cs | 46 ++++++++-- PolyPilot/Services/IWsBridgeClient.cs | 1 + PolyPilot/Services/WsBridgeClient.cs | 47 +++++++++-- PolyPilot/Services/WsBridgeServer.cs | 13 ++- 8 files changed, 251 insertions(+), 18 deletions(-) diff --git a/PolyPilot.Tests/RemoteModeTests.cs b/PolyPilot.Tests/RemoteModeTests.cs index be648a27a1..e95101b0fb 100644 --- a/PolyPilot.Tests/RemoteModeTests.cs +++ b/PolyPilot.Tests/RemoteModeTests.cs @@ -47,6 +47,7 @@ public void AllClientToServerTypes_AreUnique() BridgeMessageTypes.CloseSession, BridgeMessageTypes.AbortSession, BridgeMessageTypes.ChangeModel, + BridgeMessageTypes.RenameSession, BridgeMessageTypes.OrganizationCommand, BridgeMessageTypes.ListDirectories, }; @@ -801,4 +802,53 @@ public void ChangeModel_Serialization_CamelCase() Assert.Contains("\"sessionName\"", json); Assert.Contains("\"newModel\"", json); } + + // ========== RenameSession Protocol Tests ========== + + [Fact] + public void RenameSession_MessageType_IsCorrect() + { + Assert.Equal("rename_session", BridgeMessageTypes.RenameSession); + } + + [Fact] + public void RenameSession_RoundTrip() + { + var payload = new RenameSessionPayload { OldName = "old-name", NewName = "new-name" }; + var msg = BridgeMessage.Create(BridgeMessageTypes.RenameSession, payload); + var json = msg.Serialize(); + var restored = BridgeMessage.Deserialize(json); + + Assert.NotNull(restored); + Assert.Equal(BridgeMessageTypes.RenameSession, restored!.Type); + + var restoredPayload = restored.GetPayload(); + Assert.NotNull(restoredPayload); + Assert.Equal("old-name", restoredPayload!.OldName); + Assert.Equal("new-name", restoredPayload.NewName); + } + + [Fact] + public void RenameSessionPayload_DefaultValues() + { + var payload = new RenameSessionPayload(); + Assert.Equal("", payload.OldName); + Assert.Equal("", payload.NewName); + } + + [Fact] + public void DirectoriesListPayload_HasRequestId() + { + var request = new ListDirectoriesPayload { Path = "/tmp", RequestId = "abc123" }; + var msg = BridgeMessage.Create(BridgeMessageTypes.ListDirectories, request); + var json = msg.Serialize(); + var restored = BridgeMessage.Deserialize(json)!.GetPayload(); + Assert.Equal("abc123", restored!.RequestId); + + var response = new DirectoriesListPayload { Path = "/tmp", RequestId = "abc123" }; + var respMsg = BridgeMessage.Create(BridgeMessageTypes.DirectoriesList, response); + var respJson = respMsg.Serialize(); + var restoredResp = BridgeMessage.Deserialize(respJson)!.GetPayload(); + Assert.Equal("abc123", restoredResp!.RequestId); + } } diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index fec13f0dbb..72eda771eb 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -113,6 +113,16 @@ public Task ChangeModelAsync(string sessionName, string newModel, CancellationTo return Task.CompletedTask; } public Task SendOrganizationCommandAsync(OrganizationCommandPayload payload, CancellationToken ct = default) => Task.CompletedTask; + public string? LastRenamedOldName { get; private set; } + public string? LastRenamedNewName { get; private set; } + public int RenameSessionCallCount { get; private set; } + public Task RenameSessionAsync(string oldName, string newName, CancellationToken ct = default) + { + LastRenamedOldName = oldName; + LastRenamedNewName = newName; + RenameSessionCallCount++; + return Task.CompletedTask; + } public Task ListDirectoriesAsync(string? path = null, CancellationToken ct = default) => Task.FromResult(new DirectoriesListPayload()); } diff --git a/PolyPilot.Tests/WsBridgeIntegrationTests.cs b/PolyPilot.Tests/WsBridgeIntegrationTests.cs index c1d8ae481c..35b6b7195a 100644 --- a/PolyPilot.Tests/WsBridgeIntegrationTests.cs +++ b/PolyPilot.Tests/WsBridgeIntegrationTests.cs @@ -591,4 +591,97 @@ public async Task CreateSession_WithPathTraversal_IsRejected() Assert.Null(_copilot.GetSession("traversal-test")); client.Stop(); } + + // ========== BUG FIX REGRESSION TESTS ========== + + [Fact] + public async Task AbortSession_InDemoMode_DoesNotThrowNRE() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("abort-demo", "gpt-4.1"); + + // Start a message so IsProcessing becomes true + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await _copilot.SendPromptAsync("abort-demo", "Hello"); + await Task.Delay(50, cts.Token); // let demo start processing + + // This should NOT throw NullReferenceException (Session is null in demo mode) + await _copilot.AbortSessionAsync("abort-demo"); + + var session = _copilot.GetSession("abort-demo"); + Assert.NotNull(session); + Assert.False(session!.IsProcessing); + } + + [Fact] + public async Task RenameSession_ViaClient_RenamesOnServer() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("old-name", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.RenameSessionAsync("old-name", "new-name", cts.Token); + await Task.Delay(500, cts.Token); + + Assert.Null(_copilot.GetSession("old-name")); + Assert.NotNull(_copilot.GetSession("new-name")); + client.Stop(); + } + + [Fact] + public async Task RenameSession_ViaClient_UpdatesSessionList() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("rename-list", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.RenameSessionAsync("rename-list", "renamed-list", cts.Token); + await Task.Delay(500, cts.Token); + + // Client should receive updated session list with new name + Assert.Contains(client.Sessions, s => s.Name == "renamed-list"); + Assert.DoesNotContain(client.Sessions, s => s.Name == "rename-list"); + client.Stop(); + } + + [Fact] + public async Task CloseSession_InDemoMode_DoesNotSendBridgeMessage() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("close-demo", "gpt-4.1"); + + // Close should work in demo mode without trying to use the bridge + var result = await _copilot.CloseSessionAsync("close-demo"); + Assert.True(result); + Assert.Null(_copilot.GetSession("close-demo")); + } + + [Fact] + public async Task ListDirectories_ConcurrentCalls_BothComplete() + { + await InitDemoMode(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var client = await ConnectClientAsync(cts.Token); + + // Fire two concurrent directory listing requests + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var tmp = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + var t1 = client.ListDirectoriesAsync(home, cts.Token); + var t2 = client.ListDirectoriesAsync(tmp, cts.Token); + + var results = await Task.WhenAll(t1, t2); + + // Both should complete without hanging + Assert.All(results, r => Assert.Null(r.Error)); + // Verify we got results for both paths (order may vary) + var paths = results.Select(r => r.Path).ToHashSet(); + Assert.Contains(home, paths); + Assert.Contains(tmp, paths); + client.Stop(); + } } diff --git a/PolyPilot/Models/BridgeMessages.cs b/PolyPilot/Models/BridgeMessages.cs index 63ab9e0751..c941cafb79 100644 --- a/PolyPilot/Models/BridgeMessages.cs +++ b/PolyPilot/Models/BridgeMessages.cs @@ -82,6 +82,7 @@ public static class BridgeMessageTypes public const string OrganizationCommand = "organization_command"; public const string ListDirectories = "list_directories"; public const string ChangeModel = "change_model"; + public const string RenameSession = "rename_session"; // Server → Client (response) public const string DirectoriesList = "directories_list"; @@ -249,6 +250,12 @@ public class ChangeModelPayload public string NewModel { get; set; } = ""; } +public class RenameSessionPayload +{ + public string OldName { get; set; } = ""; + public string NewName { get; set; } = ""; +} + // --- Organization bridge payloads --- public class OrganizationCommandPayload @@ -265,6 +272,7 @@ public class OrganizationCommandPayload public class ListDirectoriesPayload { public string? Path { get; set; } + public string? RequestId { get; set; } } public class DirectoriesListPayload @@ -273,6 +281,7 @@ public class DirectoriesListPayload public List Directories { get; set; } = new(); public bool IsGitRepo { get; set; } public string? Error { get; set; } + public string? RequestId { get; set; } } public class DirectoryEntry diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 0d3d5fc4d5..07df746a65 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1615,14 +1615,18 @@ public async Task AbortSessionAsync(string sessionName) if (!state.Info.IsProcessing) return; - try - { - await state.Session.AbortAsync(); - Debug($"Aborted session '{sessionName}'"); - } - catch (Exception ex) + // In demo mode, Session is null — skip the SDK abort call + if (!IsDemoMode) { - Debug($"Abort failed for '{sessionName}': {ex.Message}"); + try + { + await state.Session.AbortAsync(); + Debug($"Aborted session '{sessionName}'"); + } + catch (Exception ex) + { + Debug($"Abort failed for '{sessionName}': {ex.Message}"); + } } // Flush any accumulated streaming content to history before clearing state. @@ -1650,6 +1654,13 @@ public async Task AbortSessionAsync(string sessionName) public void EnqueueMessage(string sessionName, string prompt, List? imagePaths = null) { + // In remote mode, delegate to bridge server + if (IsRemoteMode) + { + _ = _bridgeClient.QueueMessageAsync(sessionName, prompt); + return; + } + if (!_sessions.TryGetValue(sessionName, out var state)) throw new InvalidOperationException($"Session '{sessionName}' not found."); @@ -1808,6 +1819,25 @@ public bool RenameSession(string oldName, string newName) if (oldName == newName) return true; + // In remote mode, delegate to bridge server + if (IsRemoteMode) + { + _ = _bridgeClient.RenameSessionAsync(oldName, newName); + // Optimistically rename locally for immediate UI feedback + if (_sessions.TryRemove(oldName, out var remoteState)) + { + remoteState.Info.Name = newName; + _sessions[newName] = remoteState; + if (_activeSessionName == oldName) + _activeSessionName = newName; + var remoteMeta = Organization.Sessions.FirstOrDefault(m => m.SessionName == oldName); + if (remoteMeta != null) + remoteMeta.SessionName = newName; + OnStateChanged?.Invoke(); + } + return true; + } + if (_sessions.ContainsKey(newName)) return false; @@ -1876,7 +1906,7 @@ public async Task CloseSessionAsync(string name) StopReflectionCycle(name); // In remote mode, send close request to server - if (_bridgeClient != null && _bridgeClient.IsConnected) + if (IsRemoteMode) { await _bridgeClient.CloseSessionAsync(name); } diff --git a/PolyPilot/Services/IWsBridgeClient.cs b/PolyPilot/Services/IWsBridgeClient.cs index 57e415c38f..c3114deb06 100644 --- a/PolyPilot/Services/IWsBridgeClient.cs +++ b/PolyPilot/Services/IWsBridgeClient.cs @@ -44,6 +44,7 @@ public interface IWsBridgeClient Task CloseSessionAsync(string name, CancellationToken ct = default); Task AbortSessionAsync(string sessionName, CancellationToken ct = default); Task ChangeModelAsync(string sessionName, string newModel, CancellationToken ct = default); + Task RenameSessionAsync(string oldName, string newName, CancellationToken ct = default); Task SendOrganizationCommandAsync(OrganizationCommandPayload payload, CancellationToken ct = default); Task ListDirectoriesAsync(string? path = null, CancellationToken ct = default); } diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index fa165ee007..03de17698f 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -194,20 +194,33 @@ public async Task ChangeModelAsync(string sessionName, string newModel, Cancella await SendAsync(BridgeMessage.Create(BridgeMessageTypes.ChangeModel, new ChangeModelPayload { SessionName = sessionName, NewModel = newModel }), ct); + public async Task RenameSessionAsync(string oldName, string newName, CancellationToken ct = default) => + await SendAsync(BridgeMessage.Create(BridgeMessageTypes.RenameSession, + new RenameSessionPayload { OldName = oldName, NewName = newName }), ct); + public async Task SendOrganizationCommandAsync(OrganizationCommandPayload cmd, CancellationToken ct = default) => await SendAsync(BridgeMessage.Create(BridgeMessageTypes.OrganizationCommand, cmd), ct); - private TaskCompletionSource? _dirListTcs; + private readonly System.Collections.Concurrent.ConcurrentDictionary> _dirListRequests = new(); public async Task ListDirectoriesAsync(string? path = null, CancellationToken ct = default) { - _dirListTcs = new TaskCompletionSource(); - await SendAsync(BridgeMessage.Create(BridgeMessageTypes.ListDirectories, - new ListDirectoriesPayload { Path = path }), ct); - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); - linked.Token.Register(() => _dirListTcs.TrySetCanceled()); - return await _dirListTcs.Task; + var requestId = Guid.NewGuid().ToString("N"); + var tcs = new TaskCompletionSource(); + _dirListRequests[requestId] = tcs; + try + { + await SendAsync(BridgeMessage.Create(BridgeMessageTypes.ListDirectories, + new ListDirectoriesPayload { Path = path, RequestId = requestId }), ct); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); + linked.Token.Register(() => tcs.TrySetCanceled()); + return await tcs.Task; + } + finally + { + _dirListRequests.TryRemove(requestId, out _); + } } // --- Receive loop --- @@ -452,7 +465,23 @@ private void HandleServerMessage(string json) case BridgeMessageTypes.DirectoriesList: var dirList = msg.GetPayload(); if (dirList != null) - _dirListTcs?.TrySetResult(dirList); + { + var reqId = dirList.RequestId; + if (reqId != null && _dirListRequests.TryRemove(reqId, out var tcs)) + tcs.TrySetResult(dirList); + else + { + // Fallback: complete the first pending request (backwards compat) + foreach (var kvp in _dirListRequests) + { + if (_dirListRequests.TryRemove(kvp.Key, out var fallbackTcs)) + { + fallbackTcs.TrySetResult(dirList); + break; + } + } + } + } break; case BridgeMessageTypes.AttentionNeeded: diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 8691d9c860..306d4bd542 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -453,6 +453,17 @@ await SendToClientAsync(clientId, ws, } break; + case BridgeMessageTypes.RenameSession: + var renameReq = msg.GetPayload(); + if (renameReq != null && !string.IsNullOrWhiteSpace(renameReq.OldName) && !string.IsNullOrWhiteSpace(renameReq.NewName)) + { + Console.WriteLine($"[WsBridge] Client renaming session '{renameReq.OldName}' to '{renameReq.NewName}'"); + _copilot.RenameSession(renameReq.OldName, renameReq.NewName); + BroadcastSessionsList(); + BroadcastOrganizationState(); + } + break; + case BridgeMessageTypes.OrganizationCommand: var orgCmd = msg.GetPayload(); if (orgCmd != null) @@ -468,7 +479,7 @@ await SendToClientAsync(clientId, ws, if (string.IsNullOrWhiteSpace(dirPath)) dirPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var dirResult = new DirectoriesListPayload { Path = dirPath! }; + var dirResult = new DirectoriesListPayload { Path = dirPath!, RequestId = dirReq?.RequestId }; try { if (!Path.IsPathRooted(dirPath!) || dirPath!.Contains("..")) From 35e18941cf7b2cad301175abe625c4562448adf1 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 23:17:12 -0600 Subject: [PATCH 05/19] Fix model switching not syncing to mobile + 2 additional bugs Root cause: SyncRemoteSessions updated IsProcessing and MessageCount from server broadcasts but NOT the Model field. When ChangeModelAsync succeeded on the server and broadcast the updated sessions list, the mobile client ignored the model update for existing sessions. Also fixed: - WsBridgeServer ChangeModel handler: check return value, send error to client on failure, and always broadcast sessions list for sync - SessionErrorEvent: reset ActiveToolCallCount and HasUsedToolsThisTurn to match abort/watchdog cleanup (prevents stale tool count on next turn) - DeletePersistedSession: add IsDemoMode guard to prevent deleting real session data from disk while in demo mode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/CopilotService.Bridge.cs | 4 +++- PolyPilot/Services/CopilotService.Events.cs | 2 ++ PolyPilot/Services/CopilotService.Persistence.cs | 3 +++ PolyPilot/Services/WsBridgeServer.cs | 10 +++++++++- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 052c1b03ce..85817ab15c 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -209,11 +209,13 @@ private void SyncRemoteSessions() Info = info }; } - // Update processing state from server + // Update processing state and model from server if (_sessions.TryGetValue(rs.Name, out var state)) { state.Info.IsProcessing = rs.IsProcessing; state.Info.MessageCount = rs.MessageCount; + if (!string.IsNullOrEmpty(rs.Model)) + state.Info.Model = rs.Model; } } diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 5a92315329..c463146bbd 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -495,6 +495,8 @@ await notifService.SendNotificationAsync( case SessionErrorEvent err: var errMsg = Models.ErrorMessageHelper.HumanizeMessage(err.Data?.Message ?? "Unknown error"); CancelProcessingWatchdog(state); + Interlocked.Exchange(ref state.ActiveToolCallCount, 0); + state.HasUsedToolsThisTurn = false; InvokeOnUI(() => { OnError?.Invoke(sessionName, errMsg); diff --git a/PolyPilot/Services/CopilotService.Persistence.cs b/PolyPilot/Services/CopilotService.Persistence.cs index ac8b9e7339..810f890489 100644 --- a/PolyPilot/Services/CopilotService.Persistence.cs +++ b/PolyPilot/Services/CopilotService.Persistence.cs @@ -312,6 +312,9 @@ private static bool IsResumableSessionDirectory(DirectoryInfo di) public bool DeletePersistedSession(string sessionId) { + // In demo mode, don't delete real session data from disk + if (IsDemoMode) return false; + if (string.IsNullOrWhiteSpace(sessionId) || !Guid.TryParse(sessionId, out _)) return false; diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 306d4bd542..33fe7ee0cb 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -449,7 +449,15 @@ await SendToClientAsync(clientId, ws, if (changeModelReq != null && !string.IsNullOrWhiteSpace(changeModelReq.SessionName)) { Console.WriteLine($"[WsBridge] Client changing model for '{changeModelReq.SessionName}' to '{changeModelReq.NewModel}'"); - await _copilot.ChangeModelAsync(changeModelReq.SessionName, changeModelReq.NewModel); + var modelChanged = await _copilot.ChangeModelAsync(changeModelReq.SessionName, changeModelReq.NewModel); + if (!modelChanged) + { + await SendToClientAsync(clientId, ws, + BridgeMessage.Create(BridgeMessageTypes.ErrorEvent, + new ErrorPayload { SessionName = changeModelReq.SessionName, Error = "Failed to change model. Session may be processing or model is invalid." }), ct); + } + // Always broadcast latest session state so client stays in sync + BroadcastSessionsList(); } break; From 8c1049df15b6a59a41e27e4d06a4b7c31740b503 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 23:32:24 -0600 Subject: [PATCH 06/19] Fix: Demo mode tests corrupting active-sessions.json SaveActiveSessionsToDisk now skips writes in demo mode and during restore, preventing integration tests from overwriting the real ~/.polypilot/active-sessions.json file with empty/partial data. Also fix SessionDisposalResilienceTests to account for demo service now generating assistant responses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/SessionDisposalResilienceTests.cs | 5 +++-- PolyPilot/Services/CopilotService.Persistence.cs | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/PolyPilot.Tests/SessionDisposalResilienceTests.cs b/PolyPilot.Tests/SessionDisposalResilienceTests.cs index 1b3b126819..7cb66fbd00 100644 --- a/PolyPilot.Tests/SessionDisposalResilienceTests.cs +++ b/PolyPilot.Tests/SessionDisposalResilienceTests.cs @@ -486,12 +486,13 @@ public async Task CloseSession_DoesNotAffectOtherSessions() await svc.CreateSessionAsync("remove"); await svc.SendPromptAsync("keep", "preserved message"); + await Task.Delay(50); // Let demo response complete await svc.CloseSessionAsync("remove"); var kept = svc.GetSession("keep"); Assert.NotNull(kept); - Assert.Single(kept!.History); - Assert.Contains("preserved message", kept.History[0].Content); + Assert.True(kept!.History.Count >= 1); + Assert.Contains(kept.History, m => m.Content.Contains("preserved message")); } } diff --git a/PolyPilot/Services/CopilotService.Persistence.cs b/PolyPilot/Services/CopilotService.Persistence.cs index 810f890489..c5c55cd0a5 100644 --- a/PolyPilot/Services/CopilotService.Persistence.cs +++ b/PolyPilot/Services/CopilotService.Persistence.cs @@ -10,6 +10,10 @@ public partial class CopilotService /// private void SaveActiveSessionsToDisk() { + // Skip persistence in demo mode — demo sessions are transient + // and writing here would corrupt the real active-sessions.json + if (IsDemoMode || IsRestoring) return; + try { // Ensure directory exists (required on iOS where it may not exist by default) From ae8ffdc325be0236b60d07ff7b61469b1df11830 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 06:38:52 -0600 Subject: [PATCH 07/19] Fix: Chat history not loading on initial remote connect InitializeRemoteAsync now waits for SessionsList and SessionHistory messages to arrive from the server before returning. Previously, ConnectAsync returned as soon as the WebSocket handshake completed, but the receive loop hadn't processed the initial state yet. The Dashboard's RefreshState guard (_initializationComplete) then dropped all OnStateChanged events during initialization, so history synced by SyncRemoteSessions was never rendered until the next user action. Now we poll for sessions and history before returning, then explicitly call SyncRemoteSessions to ensure all history is in local session objects before the Dashboard renders. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/WsBridgeIntegrationTests.cs | 33 +++++++++++++++++++++ PolyPilot/Services/CopilotService.Bridge.cs | 19 +++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/PolyPilot.Tests/WsBridgeIntegrationTests.cs b/PolyPilot.Tests/WsBridgeIntegrationTests.cs index 35b6b7195a..62f17eb1e0 100644 --- a/PolyPilot.Tests/WsBridgeIntegrationTests.cs +++ b/PolyPilot.Tests/WsBridgeIntegrationTests.cs @@ -111,6 +111,39 @@ public async Task Connect_ClientReceivesHistory_ForExistingSessions() client.Stop(); } + [Fact] + public async Task Connect_RemoteService_HasHistoryImmediately() + { + // Simulate desktop: create sessions with messages + await InitDemoMode(); + await _copilot.CreateSessionAsync("session-with-history", "gpt-4.1"); + await _copilot.SendPromptAsync("session-with-history", "Test message"); + await Task.Delay(100); // Let demo response complete + + // Simulate mobile: create a remote CopilotService that connects to the bridge + var remoteService = new CopilotService( + new StubChatDatabase(), + new StubServerManager(), + new WsBridgeClient(), + new RepoManager(), + new ServiceCollection().BuildServiceProvider(), + new StubDemoService()); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await remoteService.ReconnectAsync(new ConnectionSettings + { + Mode = ConnectionMode.Remote, + RemoteUrl = $"http://localhost:{_port}/" + }, cts.Token); + + // History should be available immediately after ReconnectAsync returns + var session = remoteService.GetSession("session-with-history"); + Assert.NotNull(session); + Assert.True(session!.History.Count > 0, + "History should be synced into the remote service before ReconnectAsync returns"); + Assert.Contains(session.History, m => m.Content.Contains("Test message")); + } + // ========== SESSION LIFECYCLE ========== [Fact] diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 85817ab15c..7bda57e414 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -165,10 +165,27 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati await _bridgeClient.ConnectAsync(wsUrl, settings.RemoteToken, ct); + // Wait for initial session list from server (arrives immediately after connect) + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!_bridgeClient.Sessions.Any() && DateTime.UtcNow < deadline && !ct.IsCancellationRequested) + await Task.Delay(50, ct); + + // Allow time for SessionHistory messages to follow the SessionsList + if (_bridgeClient.Sessions.Any()) + { + var histDeadline = DateTime.UtcNow.AddSeconds(3); + while (_bridgeClient.SessionHistories.Count < _bridgeClient.Sessions.Count(s => s.MessageCount > 0) + && DateTime.UtcNow < histDeadline && !ct.IsCancellationRequested) + await Task.Delay(50, ct); + } + + // Sync all received history into local sessions before returning + SyncRemoteSessions(); + IsInitialized = true; IsRemoteMode = true; NeedsConfiguration = false; - Debug("Connected to remote server via WebSocket bridge"); + Debug($"Connected to remote server via WebSocket bridge ({_bridgeClient.Sessions.Count} sessions, {_bridgeClient.SessionHistories.Count} histories)"); OnStateChanged?.Invoke(); } From 3332c225fb5d1cdccbfeeb461f9f41c657d77b9c Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 07:15:59 -0600 Subject: [PATCH 08/19] Fix 3 bugs + add 23 tests from multi-model review Bugs fixed: - SessionHistories changed from Dictionary to ConcurrentDictionary (was read/written from 2 threads, risked InvalidOperationException) - close_session handler now guards against empty SessionName (was the only handler missing this check) - URL normalization now handles ws:// and wss:// schemes without double-prefixing (was producing wss://ws://...) New test coverage (23 tests): - ResumeSession: GUID validation, non-existent session error - Broadcast events: TurnStart/TurnEnd reach client - ChangeModel guards: processing, non-existent, same, empty - Multi-client: both receive sessions list and content deltas - Close session: empty name ignored - URL normalization: ws:// and http:// connect correctly - DiffParser: 7 tests covering all parse paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ChangeModelGuardTests.cs | 79 +++++++ PolyPilot.Tests/DiffParserTests.cs | 147 +++++++++++++ PolyPilot.Tests/PolyPilot.Tests.csproj | 1 + PolyPilot.Tests/TestStubs.cs | 3 +- PolyPilot.Tests/WsBridgeIntegrationTests.cs | 220 ++++++++++++++++++++ PolyPilot/Services/CopilotService.Bridge.cs | 3 + PolyPilot/Services/IWsBridgeClient.cs | 2 +- PolyPilot/Services/WsBridgeClient.cs | 3 +- PolyPilot/Services/WsBridgeServer.cs | 2 +- 9 files changed, 456 insertions(+), 4 deletions(-) create mode 100644 PolyPilot.Tests/ChangeModelGuardTests.cs create mode 100644 PolyPilot.Tests/DiffParserTests.cs diff --git a/PolyPilot.Tests/ChangeModelGuardTests.cs b/PolyPilot.Tests/ChangeModelGuardTests.cs new file mode 100644 index 0000000000..2501a9f119 --- /dev/null +++ b/PolyPilot.Tests/ChangeModelGuardTests.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.DependencyInjection; +using PolyPilot.Models; +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +public class ChangeModelGuardTests +{ + private CopilotService CreateService() => new( + new StubChatDatabase(), + new StubServerManager(), + new StubWsBridgeClient(), + new RepoManager(), + new ServiceCollection().BuildServiceProvider(), + new StubDemoService()); + + [Fact] + public async Task ChangeModelAsync_DemoMode_UpdatesModel() + { + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + await svc.CreateSessionAsync("test", "gpt-4.1"); + + var result = await svc.ChangeModelAsync("test", "claude-opus-4.6"); + + Assert.True(result); + Assert.Equal("claude-opus-4.6", svc.GetSession("test")!.Model); + } + + [Fact] + public async Task ChangeModelAsync_WhileProcessing_ReturnsFalse() + { + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + await svc.CreateSessionAsync("busy", "gpt-4.1"); + + svc.GetSession("busy")!.IsProcessing = true; + + var result = await svc.ChangeModelAsync("busy", "claude-opus-4.6"); + + Assert.False(result); + Assert.Equal("gpt-4.1", svc.GetSession("busy")!.Model); + } + + [Fact] + public async Task ChangeModelAsync_NonExistentSession_ReturnsFalse() + { + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var result = await svc.ChangeModelAsync("ghost", "claude-opus-4.6"); + + Assert.False(result); + } + + [Fact] + public async Task ChangeModelAsync_SameModel_ReturnsTrue() + { + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + await svc.CreateSessionAsync("same", "claude-opus-4.6"); + + var result = await svc.ChangeModelAsync("same", "claude-opus-4.6"); + + Assert.True(result); + } + + [Fact] + public async Task ChangeModelAsync_EmptyModel_ReturnsFalse() + { + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + await svc.CreateSessionAsync("empty", "gpt-4.1"); + + var result = await svc.ChangeModelAsync("empty", ""); + + Assert.False(result); + } +} diff --git a/PolyPilot.Tests/DiffParserTests.cs b/PolyPilot.Tests/DiffParserTests.cs new file mode 100644 index 0000000000..4ad3053de7 --- /dev/null +++ b/PolyPilot.Tests/DiffParserTests.cs @@ -0,0 +1,147 @@ +using PolyPilot.Models; + +namespace PolyPilot.Tests; + +public class DiffParserTests +{ + [Fact] + public void Parse_EmptyInput_ReturnsEmptyList() + { + Assert.Empty(DiffParser.Parse("")); + Assert.Empty(DiffParser.Parse(null!)); + Assert.Empty(DiffParser.Parse(" ")); + } + + [Fact] + public void Parse_StandardDiff_ExtractsFileName() + { + var diff = """ + diff --git a/src/file.cs b/src/file.cs + index abc..def 100644 + --- a/src/file.cs + +++ b/src/file.cs + @@ -1,3 +1,4 @@ + line1 + +added + line2 + line3 + """; + var files = DiffParser.Parse(diff); + Assert.Single(files); + Assert.Equal("src/file.cs", files[0].FileName); + } + + [Fact] + public void Parse_NewFile_SetsIsNew() + { + var diff = """ + diff --git a/new.txt b/new.txt + new file mode 100644 + --- /dev/null + +++ b/new.txt + @@ -0,0 +1,2 @@ + +hello + +world + """; + var files = DiffParser.Parse(diff); + Assert.Single(files); + Assert.True(files[0].IsNew); + } + + [Fact] + public void Parse_DeletedFile_SetsIsDeleted() + { + var diff = """ + diff --git a/old.txt b/old.txt + deleted file mode 100644 + --- a/old.txt + +++ /dev/null + @@ -1,2 +0,0 @@ + -hello + -world + """; + var files = DiffParser.Parse(diff); + Assert.Single(files); + Assert.True(files[0].IsDeleted); + } + + [Fact] + public void Parse_RenamedFile_SetsOldAndNewNames() + { + var diff = """ + diff --git a/old.cs b/new.cs + rename from old.cs + rename to new.cs + """; + var files = DiffParser.Parse(diff); + Assert.Single(files); + Assert.True(files[0].IsRenamed); + Assert.Equal("old.cs", files[0].OldFileName); + Assert.Equal("new.cs", files[0].FileName); + } + + [Fact] + public void Parse_HunkHeader_ExtractsLineNumbers() + { + var diff = """ + diff --git a/f.cs b/f.cs + --- a/f.cs + +++ b/f.cs + @@ -10,5 +12,7 @@ class Foo + context + """; + var files = DiffParser.Parse(diff); + var hunk = files[0].Hunks[0]; + Assert.Equal(10, hunk.OldStart); + Assert.Equal(12, hunk.NewStart); + Assert.Equal("class Foo", hunk.Header); + } + + [Fact] + public void Parse_AddedAndRemovedLines_TracksLineNumbers() + { + var diff = """ + diff --git a/f.cs b/f.cs + --- a/f.cs + +++ b/f.cs + @@ -1,3 +1,3 @@ + same + -old + +new + same2 + """; + var files = DiffParser.Parse(diff); + var lines = files[0].Hunks[0].Lines; + + Assert.Equal(4, lines.Count); + Assert.Equal(DiffLineType.Context, lines[0].Type); + Assert.Equal(DiffLineType.Removed, lines[1].Type); + Assert.Equal(2, lines[1].OldLineNo); // after context line at 1 + Assert.Equal(DiffLineType.Added, lines[2].Type); + Assert.Equal(2, lines[2].NewLineNo); // after context line at 1 + Assert.Equal(DiffLineType.Context, lines[3].Type); + } + + [Fact] + public void Parse_MultipleFiles_ParsesAll() + { + var diff = """ + diff --git a/a.cs b/a.cs + --- a/a.cs + +++ b/a.cs + @@ -1 +1 @@ + -old + +new + diff --git a/b.cs b/b.cs + --- a/b.cs + +++ b/b.cs + @@ -1 +1 @@ + -x + +y + """; + var files = DiffParser.Parse(diff); + Assert.Equal(2, files.Count); + Assert.Equal("a.cs", files[0].FileName); + Assert.Equal("b.cs", files[1].FileName); + } +} diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index 78a3b118c5..99ef05fef2 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -33,6 +33,7 @@ + diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index 72eda771eb..25422c37af 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -57,7 +57,7 @@ internal class StubWsBridgeClient : IWsBridgeClient public bool IsConnected { get; set; } public List Sessions { get; set; } = new(); public string? ActiveSessionName { get; set; } - public Dictionary> SessionHistories { get; } = new(); + public System.Collections.Concurrent.ConcurrentDictionary> SessionHistories { get; } = new(); public List PersistedSessions { get; set; } = new(); public string? GitHubAvatarUrl { get; set; } public string? GitHubLogin { get; set; } @@ -158,6 +158,7 @@ public bool TryGetSession(string name, out AgentSessionInfo? info) public async Task SimulateResponseAsync(string sessionName, string prompt, SynchronizationContext? syncContext = null, CancellationToken ct = default) { await Task.Delay(10, ct); + OnTurnStart?.Invoke(sessionName); OnContentReceived?.Invoke(sessionName, "Demo response"); if (_sessions.TryGetValue(sessionName, out var info)) { diff --git a/PolyPilot.Tests/WsBridgeIntegrationTests.cs b/PolyPilot.Tests/WsBridgeIntegrationTests.cs index 62f17eb1e0..4c0f99cc81 100644 --- a/PolyPilot.Tests/WsBridgeIntegrationTests.cs +++ b/PolyPilot.Tests/WsBridgeIntegrationTests.cs @@ -717,4 +717,224 @@ public async Task ListDirectories_ConcurrentCalls_BothComplete() Assert.Contains(tmp, paths); client.Stop(); } + + // ========== RESUME SESSION ========== + + [Fact] + public async Task ResumeSession_InvalidGuid_ReturnsErrorToClient() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + string? errorMsg = null; + client.OnError += (s, e) => errorMsg = e; + + await client.ResumeSessionAsync("../../../etc/passwd", "hack", cts.Token); + await Task.Delay(500, cts.Token); + + Assert.NotNull(errorMsg); + Assert.Contains("Invalid session ID format", errorMsg); + client.Stop(); + } + + [Fact] + public async Task ResumeSession_NonExistentGuid_ReturnsResumeFailedError() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + string? errorMsg = null; + client.OnError += (s, e) => errorMsg = e; + + await client.ResumeSessionAsync(Guid.NewGuid().ToString(), "test", cts.Token); + await Task.Delay(500, cts.Token); + + Assert.NotNull(errorMsg); + Assert.Contains("Resume failed", errorMsg); + client.Stop(); + } + + // ========== BROADCAST EVENTS ========== + + [Fact] + public async Task SendMessage_TriggersTurnStartAndTurnEnd_OnClient() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("turn-test", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + bool gotTurnStart = false; + bool gotTurnEnd = false; + client.OnTurnStart += s => { if (s == "turn-test") gotTurnStart = true; }; + client.OnTurnEnd += s => { if (s == "turn-test") gotTurnEnd = true; }; + + await _copilot.SendPromptAsync("turn-test", "Hello"); + await Task.Delay(1000, cts.Token); + + Assert.True(gotTurnStart, "Client should receive TurnStart"); + Assert.True(gotTurnEnd, "Client should receive TurnEnd"); + client.Stop(); + } + + [Fact] + public async Task SendMessage_TriggersTurnEnd_OnClient() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("complete-test", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + bool gotTurnEnd = false; + client.OnTurnEnd += s => { if (s == "complete-test") gotTurnEnd = true; }; + + await _copilot.SendPromptAsync("complete-test", "Hello"); + await Task.Delay(1000, cts.Token); + + Assert.True(gotTurnEnd, "Client should receive TurnEnd"); + client.Stop(); + } + + [Fact] + public async Task ChangeModel_WhileProcessing_SendsErrorToClient() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("model-busy", "gpt-4.1"); + + // Manually set IsProcessing + var session = _copilot.GetSession("model-busy"); + session!.IsProcessing = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + string? errorMsg = null; + client.OnError += (s, e) => errorMsg = e; + + await client.ChangeModelAsync("model-busy", "claude-opus-4.6", cts.Token); + await Task.Delay(500, cts.Token); + + Assert.NotNull(errorMsg); + client.Stop(); + } + + // ========== MULTI-CLIENT ========== + + [Fact] + public async Task MultipleClients_BothReceiveSessionsList() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client1 = await ConnectClientAsync(cts.Token); + var client2 = await ConnectClientAsync(cts.Token); + await Task.Delay(200, cts.Token); + + // Create a session — should broadcast to both + await _copilot.CreateSessionAsync("multi-test", "gpt-4.1"); + await Task.Delay(500, cts.Token); + + Assert.Contains(client1.Sessions, s => s.Name == "multi-test"); + Assert.Contains(client2.Sessions, s => s.Name == "multi-test"); + client1.Stop(); + client2.Stop(); + } + + [Fact] + public async Task MultipleClients_BothReceiveContentDelta() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("multi-content", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client1 = await ConnectClientAsync(cts.Token); + var client2 = await ConnectClientAsync(cts.Token); + + bool client1GotContent = false; + bool client2GotContent = false; + client1.OnContentReceived += (s, c) => { if (s == "multi-content") client1GotContent = true; }; + client2.OnContentReceived += (s, c) => { if (s == "multi-content") client2GotContent = true; }; + + await _copilot.SendPromptAsync("multi-content", "Hello"); + await Task.Delay(1000, cts.Token); + + Assert.True(client1GotContent, "Client 1 should receive content"); + Assert.True(client2GotContent, "Client 2 should receive content"); + client1.Stop(); + client2.Stop(); + } + + // ========== CLOSE SESSION GUARD ========== + + [Fact] + public async Task CloseSession_EmptyName_IsIgnored() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("keep-me", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + // Send close with empty session name + await client.CloseSessionAsync("", cts.Token); + await Task.Delay(500, cts.Token); + + // Session should still exist + Assert.NotNull(_copilot.GetSession("keep-me")); + client.Stop(); + } + + // ========== URL NORMALIZATION ========== + + [Fact] + public async Task Connect_WsSchemeUrl_ConnectsSuccessfully() + { + await InitDemoMode(); + + var remoteService = new CopilotService( + new StubChatDatabase(), + new StubServerManager(), + new WsBridgeClient(), + new RepoManager(), + new ServiceCollection().BuildServiceProvider(), + new StubDemoService()); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + // Pass ws:// URL directly — should NOT double-prefix to wss://ws:// + await remoteService.ReconnectAsync(new ConnectionSettings + { + Mode = ConnectionMode.Remote, + RemoteUrl = $"ws://localhost:{_port}/" + }, cts.Token); + + Assert.True(remoteService.IsInitialized); + } + + [Fact] + public async Task Connect_HttpsUrl_ConvertsToWss() + { + // We can't actually connect to wss:// in tests, but we can verify + // that the URL normalization doesn't corrupt ws:// or http:// URLs + await InitDemoMode(); + + var remoteService = new CopilotService( + new StubChatDatabase(), + new StubServerManager(), + new WsBridgeClient(), + new RepoManager(), + new ServiceCollection().BuildServiceProvider(), + new StubDemoService()); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await remoteService.ReconnectAsync(new ConnectionSettings + { + Mode = ConnectionMode.Remote, + RemoteUrl = $"http://localhost:{_port}/" + }, cts.Token); + + Assert.True(remoteService.IsInitialized); + } } diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 7bda57e414..4fe697e103 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -15,6 +15,9 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati wsUrl = "wss://" + wsUrl[8..]; else if (wsUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) wsUrl = "ws://" + wsUrl[7..]; + else if (wsUrl.StartsWith("wss://", StringComparison.OrdinalIgnoreCase) + || wsUrl.StartsWith("ws://", StringComparison.OrdinalIgnoreCase)) + { /* already a WebSocket URL */ } else wsUrl = "wss://" + wsUrl; diff --git a/PolyPilot/Services/IWsBridgeClient.cs b/PolyPilot/Services/IWsBridgeClient.cs index c3114deb06..e77648ec94 100644 --- a/PolyPilot/Services/IWsBridgeClient.cs +++ b/PolyPilot/Services/IWsBridgeClient.cs @@ -10,7 +10,7 @@ public interface IWsBridgeClient bool IsConnected { get; } List Sessions { get; } string? ActiveSessionName { get; } - Dictionary> SessionHistories { get; } + System.Collections.Concurrent.ConcurrentDictionary> SessionHistories { get; } List PersistedSessions { get; } string? GitHubAvatarUrl { get; } string? GitHubLogin { get; } diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index 03de17698f..58b00bba69 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Net.WebSockets; using System.Text; using PolyPilot.Models; @@ -22,7 +23,7 @@ public class WsBridgeClient : IWsBridgeClient, IDisposable // --- State mirroring CopilotService --- public List Sessions { get; private set; } = new(); public string? ActiveSessionName { get; private set; } - public Dictionary> SessionHistories { get; } = new(); + public ConcurrentDictionary> SessionHistories { get; } = new(); public List PersistedSessions { get; private set; } = new(); public string? GitHubAvatarUrl { get; private set; } public string? GitHubLogin { get; private set; } diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 33fe7ee0cb..ad9d38c6d0 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -428,7 +428,7 @@ await SendToClientAsync(clientId, ws, case BridgeMessageTypes.CloseSession: var closeReq = msg.GetPayload(); - if (closeReq != null) + if (closeReq != null && !string.IsNullOrWhiteSpace(closeReq.SessionName)) { Console.WriteLine($"[WsBridge] Client closing session '{closeReq.SessionName}'"); await _copilot.CloseSessionAsync(closeReq.SessionName); From 868169ac987a521d3c753af2d8298700042fb66d Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 07:39:52 -0600 Subject: [PATCH 09/19] Fix concurrent send bug, broadcast semaphore, RepoIdFromUrl, flaky tests - WsBridgeClient: Add SemaphoreSlim around SendAsync to prevent concurrent WebSocket sends (ClientWebSocket prohibits this) - WsBridgeServer: Fix Broadcast semaphore Release/Dispose ordering to prevent ObjectDisposedException and leaked waiters - RepoManager: Fix RepoIdFromUrl stripping .git from middle of repo names by only removing trailing .git suffix - WsBridgeIntegrationTests: Replace fixed Task.Delay with polling loops (WaitForAsync helper) for reliability under parallel test load - Add RepoManagerTests with 10 tests covering URL parsing and normalization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/RepoManagerTests.cs | 49 +++++++++++++++++++++ PolyPilot.Tests/WsBridgeIntegrationTests.cs | 44 +++++++++++++----- PolyPilot/Services/RepoManager.cs | 10 ++++- PolyPilot/Services/WsBridgeClient.cs | 12 ++++- PolyPilot/Services/WsBridgeServer.cs | 6 ++- 5 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 PolyPilot.Tests/RepoManagerTests.cs diff --git a/PolyPilot.Tests/RepoManagerTests.cs b/PolyPilot.Tests/RepoManagerTests.cs new file mode 100644 index 0000000000..2c53baf8eb --- /dev/null +++ b/PolyPilot.Tests/RepoManagerTests.cs @@ -0,0 +1,49 @@ +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +public class RepoManagerTests +{ + [Theory] + [InlineData("https://github.com/Owner/Repo.git", "Owner-Repo")] + [InlineData("https://github.com/Owner/Repo", "Owner-Repo")] + [InlineData("https://github.com/dotnet/maui.git", "dotnet-maui")] + [InlineData("https://gitlab.com/group/subgroup/repo.git", "group-subgroup-repo")] + [InlineData("https://github.com/owner/my.git-repo.git", "owner-my.git-repo")] // .git in middle preserved + public void RepoIdFromUrl_Https_ExtractsCorrectId(string url, string expected) + { + Assert.Equal(expected, RepoManager.RepoIdFromUrl(url)); + } + + [Theory] + [InlineData("git@github.com:Owner/Repo.git", "Owner-Repo")] + [InlineData("git@github.com:Owner/Repo", "Owner-Repo")] + public void RepoIdFromUrl_SshColon_ExtractsCorrectId(string url, string expected) + { + Assert.Equal(expected, RepoManager.RepoIdFromUrl(url)); + } + + [Theory] + [InlineData("dotnet/maui", "https://github.com/dotnet/maui")] + [InlineData("PureWeen/PolyPilot", "https://github.com/PureWeen/PolyPilot")] + public void NormalizeRepoUrl_Shorthand_ExpandsToGitHub(string input, string expected) + { + Assert.Equal(expected, RepoManager.NormalizeRepoUrl(input)); + } + + [Theory] + [InlineData("https://github.com/a/b")] + [InlineData("git@github.com:a/b.git")] + public void NormalizeRepoUrl_FullUrl_PassesThrough(string url) + { + Assert.Equal(url, RepoManager.NormalizeRepoUrl(url)); + } + + [Theory] + [InlineData("owner/repo.js")] // has a dot — not treated as shorthand + [InlineData("a/b/c")] // 3 segments — not shorthand + public void NormalizeRepoUrl_NonShorthand_PassesThrough(string input) + { + Assert.Equal(input, RepoManager.NormalizeRepoUrl(input)); + } +} diff --git a/PolyPilot.Tests/WsBridgeIntegrationTests.cs b/PolyPilot.Tests/WsBridgeIntegrationTests.cs index 4c0f99cc81..7e2672c6eb 100644 --- a/PolyPilot.Tests/WsBridgeIntegrationTests.cs +++ b/PolyPilot.Tests/WsBridgeIntegrationTests.cs @@ -18,6 +18,16 @@ public class WsBridgeIntegrationTests : IDisposable private readonly int _port; private static int _portCounter = 19100; + /// + /// Polls until a condition is true, with a timeout. Replaces fixed Task.Delay for reliability under load. + /// + private static async Task WaitForAsync(Func condition, CancellationToken ct, int pollMs = 50, int maxMs = 4000) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (!condition() && sw.ElapsedMilliseconds < maxMs) + await Task.Delay(pollMs, ct); + } + public WsBridgeIntegrationTests() { _port = Interlocked.Increment(ref _portCounter); @@ -184,7 +194,7 @@ public async Task CreateSession_BroadcastsUpdatedList_ToClient() var client = await ConnectClientAsync(cts.Token); await client.CreateSessionAsync("broadcast-test", "gpt-4.1", null, cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => client.Sessions.Any(s => s.Name == "broadcast-test"), cts.Token); Assert.Contains(client.Sessions, s => s.Name == "broadcast-test"); client.Stop(); @@ -200,7 +210,7 @@ public async Task CloseSession_RemovesFromServer() var client = await ConnectClientAsync(cts.Token); await client.CloseSessionAsync("close-me", cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => _copilot.GetSession("close-me") == null, cts.Token); Assert.Null(_copilot.GetSession("close-me")); client.Stop(); @@ -324,10 +334,10 @@ public async Task QueueMessage_EnqueuesOnServer() var client = await ConnectClientAsync(cts.Token); await client.QueueMessageAsync("queue-test", "queued msg", cts.Token); - await Task.Delay(300, cts.Token); var session = _copilot.GetSession("queue-test"); Assert.NotNull(session); + await WaitForAsync(() => session!.MessageQueue.Any(m => m.Contains("queued msg")), cts.Token); Assert.Contains(session!.MessageQueue, m => m.Contains("queued msg")); client.Stop(); } @@ -410,7 +420,7 @@ public async Task Organization_CreateGroup_AppearsOnServer() await client.SendOrganizationCommandAsync( new OrganizationCommandPayload { Command = "create_group", Name = "Mobile Group" }, cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => _copilot.Organization.Groups.Any(g => g.Name == "Mobile Group"), cts.Token); Assert.Contains(_copilot.Organization.Groups, g => g.Name == "Mobile Group"); client.Stop(); @@ -427,7 +437,7 @@ public async Task Organization_PinSession_PinsOnServer() await client.SendOrganizationCommandAsync( new OrganizationCommandPayload { Command = "pin", SessionName = "pin-me" }, cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => _copilot.Organization.Sessions.FirstOrDefault(s => s.SessionName == "pin-me")?.IsPinned == true, cts.Token); var meta = _copilot.Organization.Sessions.FirstOrDefault(s => s.SessionName == "pin-me"); Assert.NotNull(meta); @@ -486,9 +496,15 @@ public async Task Organization_RenameGroup_RenamesOnServer() await client.SendOrganizationCommandAsync( new OrganizationCommandPayload { Command = "rename_group", GroupId = group.Id, Name = "NewName" }, cts.Token); - await Task.Delay(500, cts.Token); - var renamed = _copilot.Organization.Groups.FirstOrDefault(g => g.Id == group.Id); + // Poll until the rename takes effect (server processes asynchronously) + SessionGroup? renamed = null; + for (int i = 0; i < 40; i++) + { + renamed = _copilot.Organization.Groups.FirstOrDefault(g => g.Id == group.Id); + if (renamed?.Name == "NewName") break; + await Task.Delay(100, cts.Token); + } Assert.NotNull(renamed); Assert.Equal("NewName", renamed!.Name); client.Stop(); @@ -505,7 +521,7 @@ public async Task Organization_DeleteGroup_RemovesFromServer() await client.SendOrganizationCommandAsync( new OrganizationCommandPayload { Command = "delete_group", GroupId = group.Id }, cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => !_copilot.Organization.Groups.Any(g => g.Id == group.Id), cts.Token); Assert.DoesNotContain(_copilot.Organization.Groups, g => g.Id == group.Id); client.Stop(); @@ -523,7 +539,7 @@ public async Task Organization_ToggleCollapsed_TogglesOnServer() await client.SendOrganizationCommandAsync( new OrganizationCommandPayload { Command = "toggle_collapsed", GroupId = group.Id }, cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => _copilot.Organization.Groups.FirstOrDefault(g => g.Id == group.Id)?.IsCollapsed == true, cts.Token); var updated = _copilot.Organization.Groups.FirstOrDefault(g => g.Id == group.Id); Assert.NotNull(updated); @@ -542,7 +558,13 @@ public async Task Organization_SetSortMode_UpdatesOnServer() await client.SendOrganizationCommandAsync( new OrganizationCommandPayload { Command = "set_sort", SortMode = "Alphabetical" }, cts.Token); - await Task.Delay(500, cts.Token); + + // Poll until the sort mode takes effect + for (int i = 0; i < 40; i++) + { + if (_copilot.Organization.SortMode == SessionSortMode.Alphabetical) break; + await Task.Delay(100, cts.Token); + } Assert.Equal(SessionSortMode.Alphabetical, _copilot.Organization.SortMode); client.Stop(); @@ -656,7 +678,7 @@ public async Task RenameSession_ViaClient_RenamesOnServer() var client = await ConnectClientAsync(cts.Token); await client.RenameSessionAsync("old-name", "new-name", cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => _copilot.GetSession("new-name") != null, cts.Token); Assert.Null(_copilot.GetSession("old-name")); Assert.NotNull(_copilot.GetSession("new-name")); diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index d589b3a74a..08edcbdf14 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -89,11 +89,17 @@ public static string RepoIdFromUrl(string url) if (url.Contains(':') && url.Contains('@')) { var path = url.Split(':').Last(); - return path.Replace('/', '-').TrimEnd('/').Replace(".git", ""); + var id = path.Replace('/', '-').TrimEnd('/'); + if (id.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + id = id[..^4]; + return id; } // Handle HTTPS: https://github.com/Owner/Repo.git var uri = new Uri(url); - return uri.AbsolutePath.Trim('/').Replace('/', '-').Replace(".git", ""); + var result = uri.AbsolutePath.Trim('/').Replace('/', '-'); + if (result.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + result = result[..^4]; + return result; } /// diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index 58b00bba69..517700fe25 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -17,6 +17,7 @@ public class WsBridgeClient : IWsBridgeClient, IDisposable private Task? _receiveTask; private string? _remoteWsUrl; private string? _authToken; + private readonly SemaphoreSlim _sendLock = new(1, 1); public bool IsConnected => _ws?.State == WebSocketState.Open; @@ -500,7 +501,16 @@ private async Task SendAsync(BridgeMessage msg, CancellationToken ct) { if (_ws?.State != WebSocketState.Open) return; var bytes = Encoding.UTF8.GetBytes(msg.Serialize()); - await _ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, ct); + await _sendLock.WaitAsync(ct); + try + { + if (_ws?.State == WebSocketState.Open) + await _ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, ct); + } + finally + { + _sendLock.Release(); + } } public void Dispose() diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index ad9d38c6d0..5175be07d2 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -704,11 +704,13 @@ await ws.SendAsync(new ArraySegment(bytes), catch { _clients.TryRemove(clientId, out _); - if (_clientSendLocks.TryRemove(clientId, out var lk2)) lk2.Dispose(); } finally { - sendLock.Release(); + try { sendLock.Release(); } catch (ObjectDisposedException) { } + // Clean up lock for removed clients + if (!_clients.ContainsKey(clientId)) + if (_clientSendLocks.TryRemove(clientId, out var lk)) lk.Dispose(); } }); } From b1fca491bf039d0d2f593178eb766af4a711fe4f Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 07:53:30 -0600 Subject: [PATCH 10/19] Fix 9 bugs from multi-model review round 3 WsBridgeClient: - Fix ReconnectAsync CTS race: capture CTS as local to prevent ConnectAsync from replacing it mid-loop, causing dual connections - Fix HttpMessageInvoker leak on successful connection path - Dispose _sendLock in Dispose() to prevent SemaphoreSlim leak WsBridgeServer: - Fix Broadcast ObjectDisposedException: move WaitAsync inside try block so Stop()-disposed semaphores don't cause unobserved task exceptions - Add BroadcastSessionsList after SwitchSession for multi-client sync - Guard BuildSessionsListPayload against null _copilot in HandleClientAsync CopilotService: - Fix remote-mode RenameSession: add duplicate name check before overwrite - Fix remote-mode ChangeModelAsync: add IsProcessing and same-model guards - Fix SessionStartEvent: wrap SaveActiveSessionsToDisk in Invoke() to marshal file write to UI thread, preventing concurrent file corruption - Set IsRemoteMode before SyncRemoteSessions in InitializeRemoteAsync to prevent ReconcileOrganization from running unnecessary file I/O RepoManager: ssh:// protocol URLs and credential-embedded HTTPS URLs correctly - Add tests for ssh:// and credential URL parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/RepoManagerTests.cs | 9 +++++++++ PolyPilot/Services/CopilotService.Bridge.cs | 4 +++- PolyPilot/Services/CopilotService.Events.cs | 2 +- PolyPilot/Services/CopilotService.cs | 10 +++++++++- PolyPilot/Services/RepoManager.cs | 6 +++--- PolyPilot/Services/WsBridgeClient.cs | 22 +++++++++++++++------ PolyPilot/Services/WsBridgeServer.cs | 22 ++++++++++++++++----- 7 files changed, 58 insertions(+), 17 deletions(-) diff --git a/PolyPilot.Tests/RepoManagerTests.cs b/PolyPilot.Tests/RepoManagerTests.cs index 2c53baf8eb..2927bc4fb7 100644 --- a/PolyPilot.Tests/RepoManagerTests.cs +++ b/PolyPilot.Tests/RepoManagerTests.cs @@ -23,6 +23,15 @@ public void RepoIdFromUrl_SshColon_ExtractsCorrectId(string url, string expected Assert.Equal(expected, RepoManager.RepoIdFromUrl(url)); } + [Theory] + [InlineData("ssh://git@github.com/Owner/Repo.git", "Owner-Repo")] + [InlineData("https://user@github.com/Owner/Repo.git", "Owner-Repo")] + [InlineData("https://user:token@github.com/Owner/Repo", "Owner-Repo")] + public void RepoIdFromUrl_ProtocolWithCredentials_ExtractsCorrectId(string url, string expected) + { + Assert.Equal(expected, RepoManager.RepoIdFromUrl(url)); + } + [Theory] [InlineData("dotnet/maui", "https://github.com/dotnet/maui")] [InlineData("PureWeen/PolyPilot", "https://github.com/PureWeen/PolyPilot")] diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 4fe697e103..627a73a18b 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -182,11 +182,13 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati await Task.Delay(50, ct); } + // Set IsRemoteMode before SyncRemoteSessions to prevent ReconcileOrganization from running + IsRemoteMode = true; + // Sync all received history into local sessions before returning SyncRemoteSessions(); IsInitialized = true; - IsRemoteMode = true; NeedsConfiguration = false; Debug($"Connected to remote server via WebSocket bridge ({_bridgeClient.Sessions.Count} sessions, {_bridgeClient.SessionHistories.Count} histories)"); OnStateChanged?.Invoke(); diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index c463146bbd..bac1673467 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -425,7 +425,7 @@ await notifService.SendNotificationAsync( state.Info.Model = normalizedStartModel; Debug($"Session model from start event: {startModel} → {normalizedStartModel}"); } - if (!IsRestoring) SaveActiveSessionsToDisk(); + Invoke(() => { if (!IsRestoring) SaveActiveSessionsToDisk(); }); break; case SessionUsageInfoEvent usageInfo: diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 07df746a65..3ede41758e 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1366,9 +1366,15 @@ public async Task ChangeModelAsync(string sessionName, string newModel, Ca if (!_bridgeClient.IsConnected) return false; var remoteModel = Models.ModelHelper.NormalizeToSlug(newModel); if (string.IsNullOrEmpty(remoteModel)) return false; + // Guard: don't change model while processing or if already the same + if (_sessions.TryGetValue(sessionName, out var remoteState)) + { + if (remoteState.Info.IsProcessing) return false; + if (remoteState.Info.Model == remoteModel) return true; + } await _bridgeClient.ChangeModelAsync(sessionName, remoteModel, cancellationToken); // Update local state optimistically - if (_sessions.TryGetValue(sessionName, out var remoteState)) + if (remoteState != null) remoteState.Info.Model = remoteModel; OnStateChanged?.Invoke(); return true; @@ -1822,6 +1828,8 @@ public bool RenameSession(string oldName, string newName) // In remote mode, delegate to bridge server if (IsRemoteMode) { + if (_sessions.ContainsKey(newName)) + return false; _ = _bridgeClient.RenameSessionAsync(oldName, newName); // Optimistically rename locally for immediate UI feedback if (_sessions.TryRemove(oldName, out var remoteState)) diff --git a/PolyPilot/Services/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index 08edcbdf14..08a62ed0f8 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -85,8 +85,8 @@ private void Save() /// public static string RepoIdFromUrl(string url) { - // Handle SSH: git@github.com:Owner/Repo.git - if (url.Contains(':') && url.Contains('@')) + // Handle SCP-style SSH: git@github.com:Owner/Repo.git (no :// protocol prefix) + if (url.Contains('@') && url.Contains(':') && !url.Contains("://")) { var path = url.Split(':').Last(); var id = path.Replace('/', '-').TrimEnd('/'); @@ -94,7 +94,7 @@ public static string RepoIdFromUrl(string url) id = id[..^4]; return id; } - // Handle HTTPS: https://github.com/Owner/Repo.git + // Handle HTTPS, ssh://, and other protocol URLs var uri = new Uri(url); var result = uri.AbsolutePath.Trim('/').Replace('/', '-'); if (result.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index 517700fe25..3809a3d785 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -138,6 +138,7 @@ public async Task ConnectAsync(string wsUrl, string? authToken = null, Cancellat throw; } Console.WriteLine($"[WsBridgeClient] Connected"); + invoker?.Dispose(); _receiveTask = ReceiveLoopAsync(_cts.Token); } @@ -272,12 +273,19 @@ private async Task ReconnectAsync() var maxDelay = 30_000; var delay = 2_000; - while (!(_cts?.IsCancellationRequested ?? true)) + // Capture the CTS at the start to prevent ConnectAsync from replacing it mid-loop + var cts = _cts; + if (cts == null || cts.IsCancellationRequested) return; + + while (!cts.IsCancellationRequested) { Console.WriteLine($"[WsBridgeClient] Reconnecting in {delay / 1000}s..."); - try { await Task.Delay(delay, _cts!.Token); } + try { await Task.Delay(delay, cts.Token); } catch (OperationCanceledException) { return; } + // If a new ConnectAsync replaced _cts, this reconnect loop is stale + if (_cts != cts) return; + try { _ws?.Dispose(); @@ -306,7 +314,8 @@ private async Task ReconnectAsync() } }; invoker = new HttpMessageInvoker(handler); - await _ws.ConnectAsync(uri, invoker, _cts!.Token); + await _ws.ConnectAsync(uri, invoker, cts.Token); + invoker.Dispose(); } catch { @@ -316,17 +325,17 @@ private async Task ReconnectAsync() _ws = new ClientWebSocket(); if (!string.IsNullOrEmpty(_authToken)) _ws.Options.SetRequestHeader("X-Tunnel-Authorization", $"tunnel {_authToken}"); - await _ws.ConnectAsync(uri, _cts!.Token); + await _ws.ConnectAsync(uri, cts.Token); } Console.WriteLine("[WsBridgeClient] Reconnected"); OnStateChanged?.Invoke(); // Request fresh state - await RequestSessionsAsync(_cts!.Token); + await RequestSessionsAsync(cts.Token); // Resume receive loop - _receiveTask = ReceiveLoopAsync(_cts!.Token); + _receiveTask = ReceiveLoopAsync(cts.Token); return; } catch (Exception ex) @@ -517,6 +526,7 @@ public void Dispose() { Stop(); _cts?.Dispose(); + _sendLock.Dispose(); GC.SuppressFinalize(this); } } diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 5175be07d2..3ecb97f480 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -266,10 +266,20 @@ private async Task HandleClientAsync(HttpListenerContext httpContext, Cancellati Console.WriteLine($"[WsBridge] Client {clientId} connected ({_clients.Count} total)"); // Send initial state - await SendToClientAsync(clientId, ws, - BridgeMessage.Create(BridgeMessageTypes.SessionsList, BuildSessionsListPayload()), ct); - await SendToClientAsync(clientId, ws, - BridgeMessage.Create(BridgeMessageTypes.OrganizationState, _copilot?.Organization ?? new OrganizationState()), ct); + if (_copilot != null) + { + await SendToClientAsync(clientId, ws, + BridgeMessage.Create(BridgeMessageTypes.SessionsList, BuildSessionsListPayload()), ct); + await SendToClientAsync(clientId, ws, + BridgeMessage.Create(BridgeMessageTypes.OrganizationState, _copilot.Organization), ct); + } + else + { + await SendToClientAsync(clientId, ws, + BridgeMessage.Create(BridgeMessageTypes.SessionsList, new SessionsListPayload()), ct); + await SendToClientAsync(clientId, ws, + BridgeMessage.Create(BridgeMessageTypes.OrganizationState, new OrganizationState()), ct); + } await SendPersistedToClient(clientId, ws, ct); // Send history for all active sessions so mobile has full state on connect @@ -380,6 +390,7 @@ await SendToClientAsync(clientId, ws, if (switchReq != null) { _copilot.SetActiveSession(switchReq.SessionName); + BroadcastSessionsList(); await SendSessionHistoryToClient(clientId, ws, switchReq.SessionName, ct); } break; @@ -694,13 +705,14 @@ private void Broadcast(BridgeMessage msg) var clientId = id; _ = Task.Run(async () => { - await sendLock.WaitAsync(); try { + await sendLock.WaitAsync(); if (ws.State == WebSocketState.Open) await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, CancellationToken.None); } + catch (ObjectDisposedException) { return; } catch { _clients.TryRemove(clientId, out _); From 8c6451a09fbbdb36c8535b40a9b29db0cea71391 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 08:01:11 -0600 Subject: [PATCH 11/19] Add tests: TurnEnd broadcast, auth loopback bypass, SwitchSession broadcast - TurnEnd broadcast: verify client receives turn_end event after demo prompt - Auth loopback bypass: verify connection succeeds with AccessToken set when connecting via localhost (loopback trusted) - SwitchSession broadcast: verify session list is broadcast after switch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/WsBridgeIntegrationTests.cs | 64 +++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/PolyPilot.Tests/WsBridgeIntegrationTests.cs b/PolyPilot.Tests/WsBridgeIntegrationTests.cs index 7e2672c6eb..64cadf7632 100644 --- a/PolyPilot.Tests/WsBridgeIntegrationTests.cs +++ b/PolyPilot.Tests/WsBridgeIntegrationTests.cs @@ -959,4 +959,68 @@ await remoteService.ReconnectAsync(new ConnectionSettings Assert.True(remoteService.IsInitialized); } + + // ========== TURN END BROADCAST ========== + + [Fact] + public async Task SendPrompt_Demo_ClientReceivesTurnEnd() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("turn-test", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + var turnEndReceived = new TaskCompletionSource(); + client.OnTurnEnd += session => + { + if (session == "turn-test") + turnEndReceived.TrySetResult(session); + }; + + await _copilot.SendPromptAsync("turn-test", "test prompt", cancellationToken: cts.Token); + var result = await Task.WhenAny(turnEndReceived.Task, Task.Delay(5000, cts.Token)); + + Assert.Equal(turnEndReceived.Task, result); + Assert.Equal("turn-test", await turnEndReceived.Task); + client.Stop(); + } + + // ========== AUTH TOKEN (LOOPBACK BYPASS) ========== + + [Fact] + public async Task Connect_WithAccessTokenSet_LoopbackBypassWorks() + { + _server.AccessToken = "test-secret-token-12345"; + await InitDemoMode(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + // Connect without providing the token — should still work via loopback bypass + var client = await ConnectClientAsync(cts.Token); + Assert.True(client.IsConnected); + client.Stop(); + + _server.AccessToken = null; + } + + // ========== SWITCH SESSION BROADCAST ========== + + [Fact] + public async Task SwitchSession_BroadcastsUpdatedActiveSession() + { + await InitDemoMode(); + await _copilot.CreateSessionAsync("switch-a", "gpt-4.1"); + await _copilot.CreateSessionAsync("switch-b", "gpt-4.1"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + await Task.Delay(200, cts.Token); + + await client.SwitchSessionAsync("switch-b", cts.Token); + await WaitForAsync(() => client.Sessions.Any(s => s.Name == "switch-b"), cts.Token); + + // The server should have broadcast session list with updated active session + Assert.Contains(client.Sessions, s => s.Name == "switch-b"); + client.Stop(); + } } From 38a6b1fea5ddbcafe886d5dbf5bfe22ba8f7e0b7 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 08:18:22 -0600 Subject: [PATCH 12/19] Fix 5s stall on empty-server connect in InitializeRemoteAsync Added HasReceivedSessionsList flag to WsBridgeClient to distinguish 'server replied with zero sessions' from 'server hasn't replied yet'. Previously Sessions.Any() was used which stays false for empty lists, causing a 5-second busy-wait timeout on every connect to an empty server. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/TestStubs.cs | 1 + PolyPilot/Services/CopilotService.Bridge.cs | 4 ++-- PolyPilot/Services/IWsBridgeClient.cs | 1 + PolyPilot/Services/WsBridgeClient.cs | 3 +++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index 25422c37af..4011e28714 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -55,6 +55,7 @@ public Task StartServerAsync(int port) internal class StubWsBridgeClient : IWsBridgeClient { public bool IsConnected { get; set; } + public bool HasReceivedSessionsList { get; set; } public List Sessions { get; set; } = new(); public string? ActiveSessionName { get; set; } public System.Collections.Concurrent.ConcurrentDictionary> SessionHistories { get; } = new(); diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 627a73a18b..c0d363edd2 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -170,11 +170,11 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati // Wait for initial session list from server (arrives immediately after connect) var deadline = DateTime.UtcNow.AddSeconds(5); - while (!_bridgeClient.Sessions.Any() && DateTime.UtcNow < deadline && !ct.IsCancellationRequested) + while (!_bridgeClient.HasReceivedSessionsList && DateTime.UtcNow < deadline && !ct.IsCancellationRequested) await Task.Delay(50, ct); // Allow time for SessionHistory messages to follow the SessionsList - if (_bridgeClient.Sessions.Any()) + if (_bridgeClient.HasReceivedSessionsList && _bridgeClient.Sessions.Any()) { var histDeadline = DateTime.UtcNow.AddSeconds(3); while (_bridgeClient.SessionHistories.Count < _bridgeClient.Sessions.Count(s => s.MessageCount > 0) diff --git a/PolyPilot/Services/IWsBridgeClient.cs b/PolyPilot/Services/IWsBridgeClient.cs index e77648ec94..7a8bfe8df7 100644 --- a/PolyPilot/Services/IWsBridgeClient.cs +++ b/PolyPilot/Services/IWsBridgeClient.cs @@ -8,6 +8,7 @@ namespace PolyPilot.Services; public interface IWsBridgeClient { bool IsConnected { get; } + bool HasReceivedSessionsList { get; } List Sessions { get; } string? ActiveSessionName { get; } System.Collections.Concurrent.ConcurrentDictionary> SessionHistories { get; } diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index 3809a3d785..b9efca3e48 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -20,6 +20,7 @@ public class WsBridgeClient : IWsBridgeClient, IDisposable private readonly SemaphoreSlim _sendLock = new(1, 1); public bool IsConnected => _ws?.State == WebSocketState.Open; + public bool HasReceivedSessionsList { get; private set; } // --- State mirroring CopilotService --- public List Sessions { get; private set; } = new(); @@ -146,6 +147,7 @@ public async Task ConnectAsync(string wsUrl, string? authToken = null, Cancellat public void Stop() { _cts?.Cancel(); + HasReceivedSessionsList = false; if (_ws?.State == WebSocketState.Open) { try { _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None).Wait(1000); } @@ -367,6 +369,7 @@ private void HandleServerMessage(string json) ActiveSessionName = sessions.ActiveSession; GitHubAvatarUrl = sessions.GitHubAvatarUrl; GitHubLogin = sessions.GitHubLogin; + HasReceivedSessionsList = true; Console.WriteLine($"[WsBridgeClient] Got {Sessions.Count} sessions, active={ActiveSessionName}"); OnStateChanged?.Invoke(); } From cbca0573491ee69876b1ba0e193ced6553378a52 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 08:27:25 -0600 Subject: [PATCH 13/19] Fix remaining flaky integration tests with polling Replace Task.Delay(500) with WaitForAsync polling in more bridge integration tests for reliability under parallel test load: - CreateSession_AppearsOnServer - CreateSession_WithModel_SetsModelOnServer - SendMessage_AddsUserMessageToServerHistory - ResumeSession_InvalidGuid_ReturnsErrorToClient - ResumeSession_NonExistentGuid_ReturnsResumeFailedError - ChangeModel_WhileProcessing_SendsErrorToClient All 771 tests pass consistently (5/5 runs clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/WsBridgeIntegrationTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/PolyPilot.Tests/WsBridgeIntegrationTests.cs b/PolyPilot.Tests/WsBridgeIntegrationTests.cs index 64cadf7632..6901979045 100644 --- a/PolyPilot.Tests/WsBridgeIntegrationTests.cs +++ b/PolyPilot.Tests/WsBridgeIntegrationTests.cs @@ -164,7 +164,7 @@ public async Task CreateSession_AppearsOnServer() var client = await ConnectClientAsync(cts.Token); await client.CreateSessionAsync("new-session", "gpt-4.1", null, cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => _copilot.GetSession("new-session") != null, cts.Token); Assert.NotNull(_copilot.GetSession("new-session")); client.Stop(); @@ -178,7 +178,7 @@ public async Task CreateSession_WithModel_SetsModelOnServer() var client = await ConnectClientAsync(cts.Token); await client.CreateSessionAsync("model-create", "claude-sonnet-4-5", null, cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => _copilot.GetSession("model-create") != null, cts.Token); var session = _copilot.GetSession("model-create"); Assert.NotNull(session); @@ -293,10 +293,10 @@ public async Task SendMessage_AddsUserMessageToServerHistory() var client = await ConnectClientAsync(cts.Token); await client.SendMessageAsync("msg-test", "Hello from mobile", cts.Token); - await Task.Delay(500, cts.Token); var session = _copilot.GetSession("msg-test"); Assert.NotNull(session); + await WaitForAsync(() => session!.History.Any(m => m.Content?.Contains("Hello from mobile") == true), cts.Token); Assert.Contains(session!.History, m => m.Content?.Contains("Hello from mobile") == true); client.Stop(); } @@ -753,7 +753,7 @@ public async Task ResumeSession_InvalidGuid_ReturnsErrorToClient() client.OnError += (s, e) => errorMsg = e; await client.ResumeSessionAsync("../../../etc/passwd", "hack", cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => errorMsg != null, cts.Token); Assert.NotNull(errorMsg); Assert.Contains("Invalid session ID format", errorMsg); @@ -771,7 +771,7 @@ public async Task ResumeSession_NonExistentGuid_ReturnsResumeFailedError() client.OnError += (s, e) => errorMsg = e; await client.ResumeSessionAsync(Guid.NewGuid().ToString(), "test", cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => errorMsg != null, cts.Token); Assert.NotNull(errorMsg); Assert.Contains("Resume failed", errorMsg); @@ -838,7 +838,7 @@ public async Task ChangeModel_WhileProcessing_SendsErrorToClient() client.OnError += (s, e) => errorMsg = e; await client.ChangeModelAsync("model-busy", "claude-opus-4.6", cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => errorMsg != null, cts.Token); Assert.NotNull(errorMsg); client.Stop(); From 4ebe7b875bc2dc27944a9ea0b7b86e213acf23fc Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 12:47:06 -0600 Subject: [PATCH 14/19] Fix bugs from multi-model review (6 fixes) - Fix UriFormatException crash on malformed polypilot:// QR codes (use Uri.TryCreate) - Fix event handler leak: guard InitializeRemoteAsync to only wire bridge events once - Fix RenameSession remote: check IsConnected, return false when session not found - Fix Broadcast ODE catch: clean up stale client on ObjectDisposedException - Fix WsBridgeClient SendAsync: protect _sendLock against ObjectDisposedException - Fix directory list fallback: only use legacy fallback when RequestId is null - Fix expandedSession sync: keep in sync with active session on mobile - Bigger/clearer direct share QR code (black on white, 280px) - Regenerate direct QR code after IP discovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/WsBridgeIntegrationTests.cs | 2 +- PolyPilot/Components/Pages/Dashboard.razor | 21 +++++++++++++++- PolyPilot/Components/Pages/Settings.razor | 20 ++++++++++++--- PolyPilot/Components/Pages/Settings.razor.css | 6 +++-- PolyPilot/Services/CopilotService.Bridge.cs | 9 +++++++ PolyPilot/Services/CopilotService.cs | 25 ++++++++++--------- PolyPilot/Services/WsBridgeClient.cs | 12 ++++++--- PolyPilot/Services/WsBridgeServer.cs | 6 ++++- 8 files changed, 77 insertions(+), 24 deletions(-) diff --git a/PolyPilot.Tests/WsBridgeIntegrationTests.cs b/PolyPilot.Tests/WsBridgeIntegrationTests.cs index 6901979045..1bdece1d07 100644 --- a/PolyPilot.Tests/WsBridgeIntegrationTests.cs +++ b/PolyPilot.Tests/WsBridgeIntegrationTests.cs @@ -262,7 +262,7 @@ public async Task ChangeModel_UpdatesServerSession() var client = await ConnectClientAsync(cts.Token); await client.ChangeModelAsync("model-switch", "claude-sonnet-4-5", cts.Token); - await Task.Delay(500, cts.Token); + await WaitForAsync(() => _copilot.GetSession("model-switch")?.Model == "claude-sonnet-4-5", cts.Token); Assert.Equal("claude-sonnet-4-5", _copilot.GetSession("model-switch")!.Model); client.Stop(); diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 2c5b2290a7..de20dfaf4f 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -454,6 +454,7 @@ try { + // Try JSON format first: { "url": "...", "token": "..." } var doc = System.Text.Json.JsonDocument.Parse(result); if (doc.RootElement.TryGetProperty("url", out var urlProp)) mobileRemoteUrl = urlProp.GetString() ?? ""; @@ -462,7 +463,18 @@ } catch { - mobileRemoteUrl = result; + // Try polypilot:// URI format: polypilot://connect?url=...&token=... + if (result.StartsWith("polypilot://", StringComparison.OrdinalIgnoreCase) + && Uri.TryCreate(result, UriKind.Absolute, out var uri)) + { + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + mobileRemoteUrl = query["url"] ?? ""; + mobileRemoteToken = query["token"] ?? ""; + } + else + { + mobileRemoteUrl = result; + } } // Auto-connect after scan @@ -789,6 +801,13 @@ _needsScrollToBottom = true; _ = Task.Run(() => CopilotService.SaveUiState("/dashboard", activeSession: active, expandedSession: active, expandedGrid: !isCompactGrid)); } + // Ensure expandedSession stays in sync with active session (e.g., sidebar flyout selection on mobile) + else if (active != null && expandedSession != null && expandedSession != active && sessions.Any(s => s.Name == active)) + { + _lastActiveSession = active; + expandedSession = active; + _needsScrollToBottom = true; + } if (_lastActiveSession == null && active != null) { diff --git a/PolyPilot/Components/Pages/Settings.razor b/PolyPilot/Components/Pages/Settings.razor index e0f5416cc4..c4f49982b6 100644 --- a/PolyPilot/Components/Pages/Settings.razor +++ b/PolyPilot/Components/Pages/Settings.razor @@ -675,6 +675,10 @@ if (devTunnelAvailable) tunnelLoggedIn = await DevTunnelService.IsLoggedInAsync(); + // Regenerate direct QR code now that local IPs are available + if (WsBridgeServer.IsRunning && string.IsNullOrEmpty(directQrCodeDataUri)) + GenerateDirectQrCode(); + await InvokeAsync(StateHasChanged); } @@ -778,7 +782,7 @@ var result = await QrScanner.ScanAsync(); if (string.IsNullOrEmpty(result)) return; - // QR code contains JSON { url, token } or a plain URL string + // QR code contains JSON { url, token }, a polypilot:// URI, or a plain URL string string? url = null; try { @@ -790,7 +794,17 @@ } catch { - url = result; + if (result.StartsWith("polypilot://", StringComparison.OrdinalIgnoreCase) + && Uri.TryCreate(result, UriKind.Absolute, out var uri)) + { + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + url = query["url"]; + settings.RemoteToken = query["token"]; + } + else + { + url = result; + } } if (!string.IsNullOrEmpty(url)) @@ -1152,7 +1166,7 @@ using var qr = new QRCoder.QRCodeGenerator(); using var data = qr.CreateQrCode(connectUrl, QRCoder.QRCodeGenerator.ECCLevel.M); using var png = new QRCoder.PngByteQRCode(data); - var bytes = png.GetGraphic(8, new byte[] { 255, 255, 255 }, new byte[] { 30, 30, 30 }); + var bytes = png.GetGraphic(12, new byte[] { 0, 0, 0 }, new byte[] { 255, 255, 255 }); directQrCodeDataUri = $"data:image/png;base64,{Convert.ToBase64String(bytes)}"; } catch { } diff --git a/PolyPilot/Components/Pages/Settings.razor.css b/PolyPilot/Components/Pages/Settings.razor.css index f14edd3a43..a1407c3710 100644 --- a/PolyPilot/Components/Pages/Settings.razor.css +++ b/PolyPilot/Components/Pages/Settings.razor.css @@ -723,9 +723,11 @@ } .qr-code img { - width: 200px; - height: 200px; + width: 280px; + height: 280px; border-radius: 8px; + padding: 12px; + background: #fff; } .qr-hint { diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index c0d363edd2..94e41c342e 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -5,6 +5,8 @@ namespace PolyPilot.Services; public partial class CopilotService { + private bool _bridgeEventsWired; + /// /// Initialize in Remote mode: connect WsBridgeClient for state-sync with server. /// @@ -23,6 +25,11 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati Debug($"Remote mode: connecting to {wsUrl}"); + // Wire WsBridgeClient events only once (survives reconnects) + if (!_bridgeEventsWired) + { + _bridgeEventsWired = true; + // Wire WsBridgeClient events to our events _bridgeClient.OnStateChanged += () => { @@ -166,6 +173,8 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati }); }; + } // end if (!_bridgeEventsWired) + await _bridgeClient.ConnectAsync(wsUrl, settings.RemoteToken, ct); // Wait for initial session list from server (arrives immediately after connect) diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 3ede41758e..486f6ec99f 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1828,21 +1828,22 @@ public bool RenameSession(string oldName, string newName) // In remote mode, delegate to bridge server if (IsRemoteMode) { + if (!_bridgeClient.IsConnected) + return false; if (_sessions.ContainsKey(newName)) return false; - _ = _bridgeClient.RenameSessionAsync(oldName, newName); // Optimistically rename locally for immediate UI feedback - if (_sessions.TryRemove(oldName, out var remoteState)) - { - remoteState.Info.Name = newName; - _sessions[newName] = remoteState; - if (_activeSessionName == oldName) - _activeSessionName = newName; - var remoteMeta = Organization.Sessions.FirstOrDefault(m => m.SessionName == oldName); - if (remoteMeta != null) - remoteMeta.SessionName = newName; - OnStateChanged?.Invoke(); - } + if (!_sessions.TryRemove(oldName, out var remoteState)) + return false; + _ = _bridgeClient.RenameSessionAsync(oldName, newName); + remoteState.Info.Name = newName; + _sessions[newName] = remoteState; + if (_activeSessionName == oldName) + _activeSessionName = newName; + var remoteMeta = Organization.Sessions.FirstOrDefault(m => m.SessionName == oldName); + if (remoteMeta != null) + remoteMeta.SessionName = newName; + OnStateChanged?.Invoke(); return true; } diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index b9efca3e48..0e12f82c64 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -483,9 +483,9 @@ private void HandleServerMessage(string json) var reqId = dirList.RequestId; if (reqId != null && _dirListRequests.TryRemove(reqId, out var tcs)) tcs.TrySetResult(dirList); - else + else if (reqId == null) { - // Fallback: complete the first pending request (backwards compat) + // Fallback: complete the first pending request (legacy server without RequestId) foreach (var kvp in _dirListRequests) { if (_dirListRequests.TryRemove(kvp.Key, out var fallbackTcs)) @@ -513,7 +513,11 @@ private async Task SendAsync(BridgeMessage msg, CancellationToken ct) { if (_ws?.State != WebSocketState.Open) return; var bytes = Encoding.UTF8.GetBytes(msg.Serialize()); - await _sendLock.WaitAsync(ct); + try + { + await _sendLock.WaitAsync(ct); + } + catch (ObjectDisposedException) { return; } try { if (_ws?.State == WebSocketState.Open) @@ -521,7 +525,7 @@ private async Task SendAsync(BridgeMessage msg, CancellationToken ct) } finally { - _sendLock.Release(); + try { _sendLock.Release(); } catch (ObjectDisposedException) { } } } diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 3ecb97f480..3556e7d5e7 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -712,7 +712,11 @@ private void Broadcast(BridgeMessage msg) await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, CancellationToken.None); } - catch (ObjectDisposedException) { return; } + catch (ObjectDisposedException) + { + _clients.TryRemove(clientId, out _); + return; + } catch { _clients.TryRemove(clientId, out _); From 96fef35402ebfabc406bba8703ab8f311e110b5d Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 12:57:20 -0600 Subject: [PATCH 15/19] Fix 8 issues from multi-model review round 2 - Fix ChangeModelAsync returning true for non-existent remote sessions (4/6 reviewers) - Fix RenameSession remote: add _pendingRemoteSessions protection (3/6 reviewers) - Fix RenameSession server: check return value, send ErrorEvent on failure (3/6 reviewers) - Fix SendToClientAsync: add ODE protection matching Broadcast pattern (2/6 reviewers) - Fix RenameSession fire-and-forget: add error logging continuation - Fix SwitchSession test: assert ActiveSessionName instead of list membership - Fix ListDirectoriesAsync: drain pending requests on receive loop exit - Fix HasHistoryImmediately test: replace hardcoded delay with WaitForAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/WsBridgeIntegrationTests.cs | 7 ++++--- PolyPilot/Services/CopilotService.cs | 18 ++++++++++-------- PolyPilot/Services/WsBridgeClient.cs | 6 ++++++ PolyPilot/Services/WsBridgeServer.cs | 16 +++++++++++++--- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/PolyPilot.Tests/WsBridgeIntegrationTests.cs b/PolyPilot.Tests/WsBridgeIntegrationTests.cs index 1bdece1d07..ea307f59ed 100644 --- a/PolyPilot.Tests/WsBridgeIntegrationTests.cs +++ b/PolyPilot.Tests/WsBridgeIntegrationTests.cs @@ -128,7 +128,8 @@ public async Task Connect_RemoteService_HasHistoryImmediately() await InitDemoMode(); await _copilot.CreateSessionAsync("session-with-history", "gpt-4.1"); await _copilot.SendPromptAsync("session-with-history", "Test message"); - await Task.Delay(100); // Let demo response complete + using var delayCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await WaitForAsync(() => _copilot.GetSession("session-with-history")?.History.Count > 1, delayCts.Token); // Simulate mobile: create a remote CopilotService that connects to the bridge var remoteService = new CopilotService( @@ -1017,10 +1018,10 @@ public async Task SwitchSession_BroadcastsUpdatedActiveSession() await Task.Delay(200, cts.Token); await client.SwitchSessionAsync("switch-b", cts.Token); - await WaitForAsync(() => client.Sessions.Any(s => s.Name == "switch-b"), cts.Token); + await WaitForAsync(() => client.ActiveSessionName == "switch-b", cts.Token); // The server should have broadcast session list with updated active session - Assert.Contains(client.Sessions, s => s.Name == "switch-b"); + Assert.Equal("switch-b", client.ActiveSessionName); client.Stop(); } } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 486f6ec99f..ba8e266108 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1367,15 +1367,12 @@ public async Task ChangeModelAsync(string sessionName, string newModel, Ca var remoteModel = Models.ModelHelper.NormalizeToSlug(newModel); if (string.IsNullOrEmpty(remoteModel)) return false; // Guard: don't change model while processing or if already the same - if (_sessions.TryGetValue(sessionName, out var remoteState)) - { - if (remoteState.Info.IsProcessing) return false; - if (remoteState.Info.Model == remoteModel) return true; - } + if (!_sessions.TryGetValue(sessionName, out var remoteState)) return false; + if (remoteState.Info.IsProcessing) return false; + if (remoteState.Info.Model == remoteModel) return true; await _bridgeClient.ChangeModelAsync(sessionName, remoteModel, cancellationToken); // Update local state optimistically - if (remoteState != null) - remoteState.Info.Model = remoteModel; + remoteState.Info.Model = remoteModel; OnStateChanged?.Invoke(); return true; } @@ -1835,15 +1832,20 @@ public bool RenameSession(string oldName, string newName) // Optimistically rename locally for immediate UI feedback if (!_sessions.TryRemove(oldName, out var remoteState)) return false; - _ = _bridgeClient.RenameSessionAsync(oldName, newName); remoteState.Info.Name = newName; _sessions[newName] = remoteState; + _pendingRemoteSessions[newName] = 0; if (_activeSessionName == oldName) _activeSessionName = newName; var remoteMeta = Organization.Sessions.FirstOrDefault(m => m.SessionName == oldName); if (remoteMeta != null) remoteMeta.SessionName = newName; OnStateChanged?.Invoke(); + // Send to server (fire-and-forget with error logging) + _ = _bridgeClient.RenameSessionAsync(oldName, newName) + .ContinueWith(t => Console.WriteLine($"[CopilotService] RenameSession bridge error: {t.Exception?.InnerException?.Message}"), + TaskContinuationOptions.OnlyOnFaulted); + _ = Task.Delay(30_000).ContinueWith(t => { _pendingRemoteSessions.TryRemove(newName, out _); }); return true; } diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index 0e12f82c64..af60d12ca6 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -261,6 +261,12 @@ private async Task ReceiveLoopAsync(CancellationToken ct) } Console.WriteLine("[WsBridgeClient] Receive loop ended"); + // Cancel any pending directory list requests so callers don't hang + foreach (var kvp in _dirListRequests) + { + if (_dirListRequests.TryRemove(kvp.Key, out var tcs)) + tcs.TrySetCanceled(); + } OnStateChanged?.Invoke(); // Auto-reconnect if not intentionally stopped diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 3556e7d5e7..f5a5d80605 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -477,7 +477,13 @@ await SendToClientAsync(clientId, ws, if (renameReq != null && !string.IsNullOrWhiteSpace(renameReq.OldName) && !string.IsNullOrWhiteSpace(renameReq.NewName)) { Console.WriteLine($"[WsBridge] Client renaming session '{renameReq.OldName}' to '{renameReq.NewName}'"); - _copilot.RenameSession(renameReq.OldName, renameReq.NewName); + var renamed = _copilot.RenameSession(renameReq.OldName, renameReq.NewName); + if (!renamed) + { + await SendToClientAsync(clientId, ws, + BridgeMessage.Create(BridgeMessageTypes.ErrorEvent, + new ErrorPayload { SessionName = renameReq.OldName, Error = "Failed to rename session. Name may already exist." }), ct); + } BroadcastSessionsList(); BroadcastOrganizationState(); } @@ -560,7 +566,11 @@ private async Task SendToClientAsync(string clientId, WebSocket ws, BridgeMessag if (!_clientSendLocks.TryGetValue(clientId, out var sendLock)) return; var bytes = Encoding.UTF8.GetBytes(msg.Serialize()); - await sendLock.WaitAsync(ct); + try + { + await sendLock.WaitAsync(ct); + } + catch (ObjectDisposedException) { return; } try { if (ws.State == WebSocketState.Open) @@ -568,7 +578,7 @@ private async Task SendToClientAsync(string clientId, WebSocket ws, BridgeMessag } finally { - sendLock.Release(); + try { sendLock.Release(); } catch (ObjectDisposedException) { } } } From be319c5c953776f9aeaef9c1c20c0006aecc5462 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 13:13:51 -0600 Subject: [PATCH 16/19] Fix 4 issues from multi-model review round 3 - Fix EnqueueMessage: warn about dropped images in remote mode, add error logging - Fix rename ghost duplicate: track old name in _pendingRemoteRenames to prevent SyncRemoteSessions from re-adding it during the rename window - Fix CancellationTokenSource leak: dispose old CTS in Stop() before creating new - Skip re-adding old names from pending renames in SyncRemoteSessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/CopilotService.Bridge.cs | 8 +++++++- PolyPilot/Services/CopilotService.cs | 10 +++++++++- PolyPilot/Services/WsBridgeClient.cs | 5 ++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 94e41c342e..0e14dedcb0 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -222,7 +222,7 @@ private void SyncRemoteSessions() // Add/update sessions from remote foreach (var rs in remoteSessions) { - if (!_sessions.ContainsKey(rs.Name)) + if (!_sessions.ContainsKey(rs.Name) && !_pendingRemoteRenames.ContainsKey(rs.Name)) { Debug($"SyncRemoteSessions: Adding session '{rs.Name}'"); var info = new AgentSessionInfo @@ -261,6 +261,12 @@ private void SyncRemoteSessions() // Clear pending flag for sessions confirmed by server foreach (var rs in remoteSessions) _pendingRemoteSessions.TryRemove(rs.Name, out _); + // Clear pending renames when old name no longer appears on server (rename confirmed) + foreach (var oldName in _pendingRemoteRenames.Keys.ToList()) + { + if (!remoteNames.Contains(oldName)) + _pendingRemoteRenames.TryRemove(oldName, out _); + } // Sync history from WsBridgeClient cache // Don't overwrite if local history has messages not yet reflected by server diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index ba8e266108..5e89f461cd 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -11,6 +11,8 @@ public partial class CopilotService : IAsyncDisposable private readonly ConcurrentDictionary _sessions = new(); // Sessions optimistically added during remote create/resume — protected from removal by SyncRemoteSessions private readonly ConcurrentDictionary _pendingRemoteSessions = new(); + // Old names from optimistic renames — protected from re-addition by SyncRemoteSessions + private readonly ConcurrentDictionary _pendingRemoteRenames = new(); // Sessions currently receiving streaming content via bridge events — history sync skipped to avoid duplicates private readonly ConcurrentDictionary _remoteStreamingSessions = new(); // Sessions for which history has already been requested — prevents duplicate request storms @@ -1660,7 +1662,11 @@ public void EnqueueMessage(string sessionName, string prompt, List? imag // In remote mode, delegate to bridge server if (IsRemoteMode) { - _ = _bridgeClient.QueueMessageAsync(sessionName, prompt); + if (imagePaths != null && imagePaths.Count > 0) + Console.WriteLine($"[CopilotService] Warning: image attachments not supported in remote mode, {imagePaths.Count} image(s) dropped"); + _ = _bridgeClient.QueueMessageAsync(sessionName, prompt) + .ContinueWith(t => Console.WriteLine($"[CopilotService] QueueMessage bridge error: {t.Exception?.InnerException?.Message}"), + TaskContinuationOptions.OnlyOnFaulted); return; } @@ -1835,6 +1841,7 @@ public bool RenameSession(string oldName, string newName) remoteState.Info.Name = newName; _sessions[newName] = remoteState; _pendingRemoteSessions[newName] = 0; + _pendingRemoteRenames[oldName] = 0; if (_activeSessionName == oldName) _activeSessionName = newName; var remoteMeta = Organization.Sessions.FirstOrDefault(m => m.SessionName == oldName); @@ -1846,6 +1853,7 @@ public bool RenameSession(string oldName, string newName) .ContinueWith(t => Console.WriteLine($"[CopilotService] RenameSession bridge error: {t.Exception?.InnerException?.Message}"), TaskContinuationOptions.OnlyOnFaulted); _ = Task.Delay(30_000).ContinueWith(t => { _pendingRemoteSessions.TryRemove(newName, out _); }); + _ = Task.Delay(30_000).ContinueWith(t => { _pendingRemoteRenames.TryRemove(oldName, out _); }); return true; } diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index af60d12ca6..a5abe1068d 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -146,7 +146,10 @@ public async Task ConnectAsync(string wsUrl, string? authToken = null, Cancellat public void Stop() { - _cts?.Cancel(); + var oldCts = _cts; + _cts = null; + oldCts?.Cancel(); + try { oldCts?.Dispose(); } catch { } HasReceivedSessionsList = false; if (_ws?.State == WebSocketState.Open) { From 2134ce0cf10ed9693229ff212abb5ec805538bb7 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 13:24:58 -0600 Subject: [PATCH 17/19] Fix ChangeModelAsync remote exception propagation (round 4) - Wrap remote ChangeModelAsync bridge call in try/catch to match local path behavior (callers don't expect exceptions from this method) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/CopilotService.Bridge.cs | 3 ++- PolyPilot/Services/CopilotService.cs | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 0e14dedcb0..174fb40c54 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -261,7 +261,8 @@ private void SyncRemoteSessions() // Clear pending flag for sessions confirmed by server foreach (var rs in remoteSessions) _pendingRemoteSessions.TryRemove(rs.Name, out _); - // Clear pending renames when old name no longer appears on server (rename confirmed) + // Clear pending renames when old name disappears from server (rename confirmed). + // If rename fails, old name stays on server and the 30s TTL cleanup handles it. foreach (var oldName in _pendingRemoteRenames.Keys.ToList()) { if (!remoteNames.Contains(oldName)) diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 5e89f461cd..e369442173 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1372,7 +1372,15 @@ public async Task ChangeModelAsync(string sessionName, string newModel, Ca if (!_sessions.TryGetValue(sessionName, out var remoteState)) return false; if (remoteState.Info.IsProcessing) return false; if (remoteState.Info.Model == remoteModel) return true; - await _bridgeClient.ChangeModelAsync(sessionName, remoteModel, cancellationToken); + try + { + await _bridgeClient.ChangeModelAsync(sessionName, remoteModel, cancellationToken); + } + catch (Exception ex) + { + Debug($"ChangeModelAsync remote error: {ex.Message}"); + return false; + } // Update local state optimistically remoteState.Info.Model = remoteModel; OnStateChanged?.Invoke(); From 89897cc3bf803a5e96807cfa7cb35cc291b4b5f2 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 13:47:13 -0600 Subject: [PATCH 18/19] Fix HandleComplete regression: remove unnecessary InvokeAsync wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HandleComplete is called from CompleteResponse which is already marshaled to the UI thread via Invoke(SynchronizationContext.Post). The InvokeAsync wrapper queued a second deferral, causing StateHasChanged() to run too late — after other RefreshState calls consumed the dirty flag, so the DOM never updated with IsProcessing=false. Keep InvokeAsync only on the 10-second delayed cleanup callback which runs on the thread-pool via Task.Delay.ContinueWith. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/Pages/Dashboard.razor | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index de20dfaf4f..949487e6d0 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -836,18 +836,15 @@ private void HandleComplete(string sessionName, string summary) { - // Marshal all state mutations + render onto the UI thread. - // HandleComplete can be called from background threads (SDK event dispatch). - // Collections like completedSessions/streamingBySession are not thread-safe. - InvokeAsync(() => - { - if (_disposed) return; - completedSessions.Add(sessionName); - streamingBySession.Remove(sessionName); - activityBySession.Remove(sessionName); - sessions = CopilotService.GetAllSessions().ToList(); - StateHasChanged(); - }); + // Already on UI thread (called from CompleteResponse via Invoke/SynchronizationContext.Post). + // Must be synchronous — InvokeAsync defers and causes stale renders. + if (_disposed) return; + completedSessions.Add(sessionName); + streamingBySession.Remove(sessionName); + activityBySession.Remove(sessionName); + sessions = CopilotService.GetAllSessions().ToList(); + StateHasChanged(); + // This callback runs on thread-pool — DOES need InvokeAsync. _ = Task.Delay(10000).ContinueWith(_ => InvokeAsync(() => { From 2ec79205c5622cceacbfa09a7b244b1362377eb3 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 20 Feb 2026 13:49:50 -0600 Subject: [PATCH 19/19] Add regression test: HandleComplete must not use InvokeAsync Source-scanning test that verifies HandleComplete in Dashboard.razor has at most one InvokeAsync call (the delayed cleanup only), and that call must be inside a Task.Delay continuation. If someone wraps the body in InvokeAsync again, the test fails with an explanatory message about why it causes stuck 'Thinking' indicators. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/RenderThrottleTests.cs | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/PolyPilot.Tests/RenderThrottleTests.cs b/PolyPilot.Tests/RenderThrottleTests.cs index b2ef794abe..9bff0e8a95 100644 --- a/PolyPilot.Tests/RenderThrottleTests.cs +++ b/PolyPilot.Tests/RenderThrottleTests.cs @@ -112,4 +112,59 @@ public void CompletionRace_OnStateChangedThrottledButHandleCompleteRenders() // Step 3: HandleComplete runs, adds to completedSessions, calls StateHasChanged directly // (bypasses RefreshState/throttle entirely — this is the fix) } + + [Fact] + public void HandleComplete_MustNotUseInvokeAsync_RegressionGuard() + { + // Regression guard: HandleComplete is called from CompleteResponse which is + // already marshaled to the UI thread via Invoke(SynchronizationContext.Post). + // Wrapping the body in InvokeAsync DEFERS execution, causing StateHasChanged() + // to run too late — after other RefreshState calls consume the dirty flag. + // The DOM then never updates with IsProcessing=false ("stuck Thinking" bug). + // + // Only the delayed 10-second cleanup callback should use InvokeAsync + // (because Task.Delay.ContinueWith runs on the thread pool). + var dashboardPath = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", + "PolyPilot", "Components", "Pages", "Dashboard.razor"); + Assert.True(File.Exists(dashboardPath), $"Dashboard.razor not found at {dashboardPath}"); + + var source = File.ReadAllText(dashboardPath); + + // Extract the HandleComplete method body (from signature to next "private " or "protected ") + var methodStart = source.IndexOf("private void HandleComplete(", StringComparison.Ordinal); + Assert.True(methodStart >= 0, "HandleComplete method not found in Dashboard.razor"); + + // Find the next method definition after HandleComplete + var afterSignature = source.IndexOf('\n', methodStart) + 1; + var nextMethod = source.IndexOf("\n private ", afterSignature, StringComparison.Ordinal); + if (nextMethod < 0) nextMethod = source.Length; + var methodBody = source.Substring(methodStart, nextMethod - methodStart); + + // Count InvokeAsync calls — should have exactly 1 (the delayed cleanup only) + var invokeAsyncCount = 0; + var searchFrom = 0; + while (true) + { + var idx = methodBody.IndexOf("InvokeAsync(", searchFrom, StringComparison.Ordinal); + if (idx < 0) break; + invokeAsyncCount++; + searchFrom = idx + 1; + } + + Assert.True(invokeAsyncCount <= 1, + $"HandleComplete has {invokeAsyncCount} InvokeAsync calls — expected at most 1 (the delayed cleanup). " + + "The synchronous body must NOT use InvokeAsync because HandleComplete is already on the UI thread " + + "(called from CompleteResponse via Invoke/SynchronizationContext.Post). " + + "InvokeAsync defers execution and causes stale renders (stuck 'Thinking' indicators)."); + + // The one allowed InvokeAsync must be inside a Task.Delay continuation + if (invokeAsyncCount == 1) + { + var invokeIdx = methodBody.IndexOf("InvokeAsync(", StringComparison.Ordinal); + var precedingContext = methodBody.Substring(Math.Max(0, invokeIdx - 100), Math.Min(100, invokeIdx)); + Assert.Contains("Task.Delay", precedingContext, + StringComparison.Ordinal); + } + } }