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();
}
});
}