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 f5d432a49c..99ef05fef2 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -33,6 +33,7 @@ + @@ -48,6 +49,7 @@ + diff --git a/PolyPilot.Tests/RemoteModeTests.cs b/PolyPilot.Tests/RemoteModeTests.cs index 88f80b6599..e95101b0fb 100644 --- a/PolyPilot.Tests/RemoteModeTests.cs +++ b/PolyPilot.Tests/RemoteModeTests.cs @@ -46,6 +46,8 @@ public void AllClientToServerTypes_AreUnique() BridgeMessageTypes.QueueMessage, BridgeMessageTypes.CloseSession, BridgeMessageTypes.AbortSession, + BridgeMessageTypes.ChangeModel, + BridgeMessageTypes.RenameSession, BridgeMessageTypes.OrganizationCommand, BridgeMessageTypes.ListDirectories, }; @@ -758,4 +760,95 @@ 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); + } + + // ========== 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/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); + } + } } diff --git a/PolyPilot.Tests/RepoManagerTests.cs b/PolyPilot.Tests/RepoManagerTests.cs new file mode 100644 index 0000000000..2927bc4fb7 --- /dev/null +++ b/PolyPilot.Tests/RepoManagerTests.cs @@ -0,0 +1,58 @@ +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("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")] + 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/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.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index 865dde1d07..4011e28714 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -55,9 +55,10 @@ 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 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; } @@ -102,7 +103,27 @@ 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 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()); } @@ -135,7 +156,18 @@ 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); + OnTurnStart?.Invoke(sessionName); + 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 new file mode 100644 index 0000000000..ea307f59ed --- /dev/null +++ b/PolyPilot.Tests/WsBridgeIntegrationTests.cs @@ -0,0 +1,1027 @@ +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; + 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); + _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 }); + } + + // ========== CONNECTION ========== + + [Fact] + public async Task Connect_ClientReceivesConnectedState() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + Assert.True(client.IsConnected); + client.Stop(); + } + + [Fact] + 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"); + + 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); + + Assert.True(client.SessionHistories.ContainsKey("history-test")); + Assert.True(client.SessionHistories["history-test"].Count > 0); + 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"); + 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( + 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] + public async Task CreateSession_AppearsOnServer() + { + await InitDemoMode(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var client = await ConnectClientAsync(cts.Token); + + await client.CreateSessionAsync("new-session", "gpt-4.1", null, cts.Token); + await WaitForAsync(() => _copilot.GetSession("new-session") != null, cts.Token); + + Assert.NotNull(_copilot.GetSession("new-session")); + client.Stop(); + } + + [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.CreateSessionAsync("model-create", "claude-sonnet-4-5", null, cts.Token); + await WaitForAsync(() => _copilot.GetSession("model-create") != null, cts.Token); + + var session = _copilot.GetSession("model-create"); + Assert.NotNull(session); + Assert.Equal("claude-sonnet-4-5", session!.Model); + client.Stop(); + } + + [Fact] + 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 WaitForAsync(() => client.Sessions.Any(s => s.Name == "broadcast-test"), 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 WaitForAsync(() => _copilot.GetSession("close-me") == null, 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); + + await client.AbortSessionAsync("abort-test", cts.Token); + await Task.Delay(300, cts.Token); + + 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 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(); + } + + [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); + + 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(); + } + + [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); + + 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(); + } + + // ========== 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 WaitForAsync(() => _copilot.Organization.Groups.Any(g => g.Name == "Mobile Group"), 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 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); + 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 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); + + // 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(); + } + + [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.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "delete_group", GroupId = group.Id }, 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(); + } + + [Fact] + 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 client.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "toggle_collapsed", GroupId = group.Id }, 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); + 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); + + await client.SendOrganizationCommandAsync( + new OrganizationCommandPayload { Command = "set_sort", SortMode = "Alphabetical" }, 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(); + } + + [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 == "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(); + } + + // ========== 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 WaitForAsync(() => _copilot.GetSession("new-name") != null, 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(); + } + + // ========== 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 WaitForAsync(() => errorMsg != null, 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 WaitForAsync(() => errorMsg != null, 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 WaitForAsync(() => errorMsg != null, 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); + } + + // ========== 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.ActiveSessionName == "switch-b", cts.Token); + + // The server should have broadcast session list with updated active session + Assert.Equal("switch-b", client.ActiveSessionName); + client.Stop(); + } +} diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 2c5b2290a7..949487e6d0 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) { @@ -817,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(() => { 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/Models/BridgeMessages.cs b/PolyPilot/Models/BridgeMessages.cs index 5766ceade7..c941cafb79 100644 --- a/PolyPilot/Models/BridgeMessages.cs +++ b/PolyPilot/Models/BridgeMessages.cs @@ -81,6 +81,8 @@ 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"; + public const string RenameSession = "rename_session"; // Server → Client (response) public const string DirectoriesList = "directories_list"; @@ -242,6 +244,18 @@ public class ResumeSessionPayload public string? DisplayName { get; set; } } +public class ChangeModelPayload +{ + public string SessionName { get; set; } = ""; + public string NewModel { get; set; } = ""; +} + +public class RenameSessionPayload +{ + public string OldName { get; set; } = ""; + public string NewName { get; set; } = ""; +} + // --- Organization bridge payloads --- public class OrganizationCommandPayload @@ -258,6 +272,7 @@ public class OrganizationCommandPayload public class ListDirectoriesPayload { public string? Path { get; set; } + public string? RequestId { get; set; } } public class DirectoriesListPayload @@ -266,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.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 052c1b03ce..174fb40c54 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. /// @@ -15,11 +17,19 @@ 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; 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 += () => { @@ -163,12 +173,33 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati }); }; + } // end if (!_bridgeEventsWired) + await _bridgeClient.ConnectAsync(wsUrl, settings.RemoteToken, ct); - IsInitialized = true; + // Wait for initial session list from server (arrives immediately after connect) + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!_bridgeClient.HasReceivedSessionsList && DateTime.UtcNow < deadline && !ct.IsCancellationRequested) + await Task.Delay(50, ct); + + // Allow time for SessionHistory messages to follow the SessionsList + if (_bridgeClient.HasReceivedSessionsList && _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); + } + + // Set IsRemoteMode before SyncRemoteSessions to prevent ReconcileOrganization from running IsRemoteMode = true; + + // Sync all received history into local sessions before returning + SyncRemoteSessions(); + + IsInitialized = 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(); } @@ -191,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 @@ -209,11 +240,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; } } @@ -228,6 +261,13 @@ 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 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)) + _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.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 5a92315329..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: @@ -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..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) @@ -312,6 +316,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/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 3fb1f352a9..e369442173 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 @@ -1212,6 +1214,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; } @@ -1359,9 +1363,32 @@ 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; + // Guard: don't change model while processing or if already the same + if (!_sessions.TryGetValue(sessionName, out var remoteState)) return false; + if (remoteState.Info.IsProcessing) return false; + if (remoteState.Info.Model == remoteModel) return true; + 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(); + 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; var normalizedModel = Models.ModelHelper.NormalizeToSlug(newModel); if (string.IsNullOrEmpty(normalizedModel)) return false; @@ -1369,6 +1396,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 @@ -1591,14 +1628,18 @@ public async Task AbortSessionAsync(string sessionName) if (!state.Info.IsProcessing) return; - try + // In demo mode, Session is null — skip the SDK abort call + if (!IsDemoMode) { - await state.Session.AbortAsync(); - Debug($"Aborted session '{sessionName}'"); - } - catch (Exception ex) - { - 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. @@ -1626,6 +1667,17 @@ 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) + { + 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; + } + if (!_sessions.TryGetValue(sessionName, out var state)) throw new InvalidOperationException($"Session '{sessionName}' not found."); @@ -1784,6 +1836,35 @@ public bool RenameSession(string oldName, string newName) if (oldName == newName) return true; + // In remote mode, delegate to bridge server + if (IsRemoteMode) + { + if (!_bridgeClient.IsConnected) + return false; + if (_sessions.ContainsKey(newName)) + return false; + // Optimistically rename locally for immediate UI feedback + if (!_sessions.TryRemove(oldName, out var remoteState)) + return false; + 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); + 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 _); }); + _ = Task.Delay(30_000).ContinueWith(t => { _pendingRemoteRenames.TryRemove(oldName, out _); }); + return true; + } + if (_sessions.ContainsKey(newName)) return false; @@ -1852,7 +1933,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 f80e82f345..7a8bfe8df7 100644 --- a/PolyPilot/Services/IWsBridgeClient.cs +++ b/PolyPilot/Services/IWsBridgeClient.cs @@ -8,9 +8,10 @@ namespace PolyPilot.Services; public interface IWsBridgeClient { bool IsConnected { get; } + bool HasReceivedSessionsList { 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; } @@ -43,6 +44,8 @@ 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 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/RepoManager.cs b/PolyPilot/Services/RepoManager.cs index d589b3a74a..08a62ed0f8 100644 --- a/PolyPilot/Services/RepoManager.cs +++ b/PolyPilot/Services/RepoManager.cs @@ -85,15 +85,21 @@ 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(); - 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 + // Handle HTTPS, ssh://, and other protocol URLs 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 9e2c6cbdc2..a5abe1068d 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; @@ -16,13 +17,15 @@ 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; + public bool HasReceivedSessionsList { get; private set; } // --- 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; } @@ -136,13 +139,18 @@ public async Task ConnectAsync(string wsUrl, string? authToken = null, Cancellat throw; } Console.WriteLine($"[WsBridgeClient] Connected"); + invoker?.Dispose(); _receiveTask = ReceiveLoopAsync(_cts.Token); } public void Stop() { - _cts?.Cancel(); + var oldCts = _cts; + _cts = null; + oldCts?.Cancel(); + try { oldCts?.Dispose(); } catch { } + HasReceivedSessionsList = false; if (_ws?.State == WebSocketState.Open) { try { _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None).Wait(1000); } @@ -190,20 +198,37 @@ 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 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 --- @@ -239,6 +264,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 @@ -253,12 +284,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(); @@ -287,7 +325,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 { @@ -297,17 +336,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) @@ -339,6 +378,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(); } @@ -448,7 +488,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 if (reqId == null) + { + // Fallback: complete the first pending request (legacy server without RequestId) + foreach (var kvp in _dirListRequests) + { + if (_dirListRequests.TryRemove(kvp.Key, out var fallbackTcs)) + { + fallbackTcs.TrySetResult(dirList); + break; + } + } + } + } break; case BridgeMessageTypes.AttentionNeeded: @@ -466,13 +522,27 @@ 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); + try + { + await _sendLock.WaitAsync(ct); + } + catch (ObjectDisposedException) { return; } + try + { + if (_ws?.State == WebSocketState.Open) + await _ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, ct); + } + finally + { + try { _sendLock.Release(); } catch (ObjectDisposedException) { } + } } public void Dispose() { Stop(); _cts?.Dispose(); + _sendLock.Dispose(); GC.SuppressFinalize(this); } } diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index b9ecd732d5..f5a5d80605 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; @@ -428,7 +439,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); @@ -444,6 +455,40 @@ 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}'"); + 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; + + 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}'"); + 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(); + } + break; + case BridgeMessageTypes.OrganizationCommand: var orgCmd = msg.GetPayload(); if (orgCmd != null) @@ -459,7 +504,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("..")) @@ -521,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) @@ -529,7 +578,7 @@ private async Task SendToClientAsync(string clientId, WebSocket ws, BridgeMessag } finally { - sendLock.Release(); + try { sendLock.Release(); } catch (ObjectDisposedException) { } } } @@ -666,21 +715,28 @@ 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) + { + _clients.TryRemove(clientId, out _); + return; + } 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(); } }); }