Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5fdc345
Fix: Model switching not working in remote mode (mobile/devtunnel)
PureWeen Feb 20, 2026
658c2c7
Add WsBridge integration tests with real localhost WebSocket
PureWeen Feb 20, 2026
233228e
Add comprehensive WsBridge integration tests covering all bridge oper…
PureWeen Feb 20, 2026
13cb3e1
Fix 5 production bugs found by multi-model review
PureWeen Feb 20, 2026
35e1894
Fix model switching not syncing to mobile + 2 additional bugs
PureWeen Feb 20, 2026
8c1049d
Fix: Demo mode tests corrupting active-sessions.json
PureWeen Feb 20, 2026
ae8ffdc
Fix: Chat history not loading on initial remote connect
PureWeen Feb 20, 2026
3332c22
Fix 3 bugs + add 23 tests from multi-model review
PureWeen Feb 20, 2026
868169a
Fix concurrent send bug, broadcast semaphore, RepoIdFromUrl, flaky tests
PureWeen Feb 20, 2026
b1fca49
Fix 9 bugs from multi-model review round 3
PureWeen Feb 20, 2026
8c6451a
Add tests: TurnEnd broadcast, auth loopback bypass, SwitchSession bro…
PureWeen Feb 20, 2026
38a6b1f
Fix 5s stall on empty-server connect in InitializeRemoteAsync
PureWeen Feb 20, 2026
cbca057
Fix remaining flaky integration tests with polling
PureWeen Feb 20, 2026
4ebe7b8
Fix bugs from multi-model review (6 fixes)
PureWeen Feb 20, 2026
96fef35
Fix 8 issues from multi-model review round 2
PureWeen Feb 20, 2026
be319c5
Fix 4 issues from multi-model review round 3
PureWeen Feb 20, 2026
2134ce0
Fix ChangeModelAsync remote exception propagation (round 4)
PureWeen Feb 20, 2026
89897cc
Fix HandleComplete regression: remove unnecessary InvokeAsync wrapper
PureWeen Feb 20, 2026
2ec7920
Add regression test: HandleComplete must not use InvokeAsync
PureWeen Feb 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions PolyPilot.Tests/ChangeModelGuardTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
147 changes: 147 additions & 0 deletions PolyPilot.Tests/DiffParserTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 2 additions & 0 deletions PolyPilot.Tests/PolyPilot.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<Compile Include="../PolyPilot/Models/ModelHelper.cs" Link="Shared/ModelHelper.cs" />
<Compile Include="../PolyPilot/Models/RepositoryInfo.cs" Link="Shared/RepositoryInfo.cs" />
<Compile Include="../PolyPilot/Models/RenderThrottle.cs" Link="Shared/RenderThrottle.cs" />
<Compile Include="../PolyPilot/Models/DiffParser.cs" Link="Shared/DiffParser.cs" />
<Compile Include="../PolyPilot/Services/NotificationMessageBuilder.cs" Link="Shared/NotificationMessageBuilder.cs" />
<Compile Include="../PolyPilot/Services/IChatDatabase.cs" Link="Shared/IChatDatabase.cs" />
<Compile Include="../PolyPilot/Services/IServerManager.cs" Link="Shared/IServerManager.cs" />
Expand All @@ -48,6 +49,7 @@
<Compile Include="../PolyPilot/Services/CopilotService.Persistence.cs" Link="Shared/CopilotService.Persistence.cs" />
<Compile Include="../PolyPilot/Services/CopilotService.Utilities.cs" Link="Shared/CopilotService.Utilities.cs" />
<Compile Include="../PolyPilot/Services/WsBridgeServer.cs" Link="Shared/WsBridgeServer.cs" />
<Compile Include="../PolyPilot/Services/WsBridgeClient.cs" Link="Shared/WsBridgeClient.cs" />
<Compile Include="../PolyPilot/Services/DevTunnelService.cs" Link="Shared/DevTunnelService.cs" />
<Compile Include="../PolyPilot/Services/FiestaService.cs" Link="Shared/FiestaService.cs" />
<Compile Include="../PolyPilot/Models/ReflectionCycle.cs" Link="Shared/ReflectionCycle.cs" />
Expand Down
93 changes: 93 additions & 0 deletions PolyPilot.Tests/RemoteModeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
BridgeMessageTypes.QueueMessage,
BridgeMessageTypes.CloseSession,
BridgeMessageTypes.AbortSession,
BridgeMessageTypes.ChangeModel,
BridgeMessageTypes.RenameSession,
BridgeMessageTypes.OrganizationCommand,
BridgeMessageTypes.ListDirectories,
};
Expand Down Expand Up @@ -686,7 +688,7 @@
{
// WorkingDirectory containing ".." should be rejected
var wd = "/Users/test/../../../etc/passwd";
Assert.True(wd.Contains(".."));

Check warning on line 691 in PolyPilot.Tests/RemoteModeTests.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Do not use Assert.True() to check for substrings. Use Assert.Contains instead. (https://xunit.net/xunit.analyzers/rules/xUnit2009)

Check warning on line 691 in PolyPilot.Tests/RemoteModeTests.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Do not use Assert.True() to check for substrings. Use Assert.Contains instead. (https://xunit.net/xunit.analyzers/rules/xUnit2009)
}

// ===== SyncRemoteSessions streaming guard =====
Expand Down Expand Up @@ -758,4 +760,95 @@

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<ChangeModelPayload>();
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<RenameSessionPayload>();
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<ListDirectoriesPayload>();
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<DirectoriesListPayload>();
Assert.Equal("abc123", restoredResp!.RequestId);
}
}
Loading
Loading