From d226473d448f2a5e2c6e7d199da3d55b655e2598 Mon Sep 17 00:00:00 2001 From: vitek-karas <10670590+vitek-karas@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:02:08 +0200 Subject: [PATCH 1/4] Ask Copilot CLI for the list of models instead of hardcoding it. --- PolyPilot.Tests/ModelSelectionTests.cs | 32 ++- PolyPilot.Tests/ScenarioReferenceTests.cs | 5 +- .../Scenarios/mode-switch-scenarios.json | 7 +- PolyPilot.Tests/StateChangeCoalescerTests.cs | 24 +- .../Components/Layout/SessionListItem.razor | 5 +- .../Components/Layout/SessionSidebar.razor | 4 +- PolyPilot/Components/Pages/Dashboard.razor | 2 +- PolyPilot/Models/ModelHelper.cs | 29 ++ .../Services/CopilotService.Utilities.cs | 263 ++++++++++++++++-- PolyPilot/Services/CopilotService.cs | 8 + 10 files changed, 334 insertions(+), 45 deletions(-) diff --git a/PolyPilot.Tests/ModelSelectionTests.cs b/PolyPilot.Tests/ModelSelectionTests.cs index 4f4a8d00e5..84f8bbe8fc 100644 --- a/PolyPilot.Tests/ModelSelectionTests.cs +++ b/PolyPilot.Tests/ModelSelectionTests.cs @@ -117,11 +117,22 @@ public void NormalizeToSlug_AllFallbackModels_AreAlreadySlugs() } [Fact] - public void FallbackModels_IncludeCurrentGptVariants() + public void BuildSelectionList_AppendsSelectionAndDefault_WhenDiscoveryIsEmpty() { - Assert.Contains("gpt-5.4", ModelHelper.FallbackModels); - Assert.Contains("gpt-5.4-mini", ModelHelper.FallbackModels); - Assert.Contains("gpt-5.3-codex", ModelHelper.FallbackModels); + var models = ModelHelper.BuildSelectionList(Array.Empty(), "Custom Preview", "claude-opus-4.6"); + + Assert.Equal(new[] { "custom-preview", "claude-opus-4.6" }, models); + } + + [Fact] + public void BuildSelectionList_NormalizesDiscoveredModels_AndAvoidsDuplicates() + { + var models = ModelHelper.BuildSelectionList( + new[] { "Claude Opus 4.6", "claude-opus-4.6", "Custom Preview" }, + "custom-preview", + "claude-opus-4.6"); + + Assert.Equal(new[] { "claude-opus-4.6", "custom-preview" }, models); } [Fact] @@ -493,6 +504,19 @@ public void ChangeModel_DisplayNameFromDropdown_NormalizesToSlug() } } + [Fact] + public void BuildSelectionList_PreservesDiscoveryOrder_AndAppendsMissingRequiredModels() + { + var models = ModelHelper.BuildSelectionList( + new[] { "claude-sonnet-4.6", "Claude Opus 4.6", "gpt-5.4" }, + "claude-opus-4.6", + "gpt-5-mini"); + + Assert.Equal( + new[] { "claude-sonnet-4.6", "claude-opus-4.6", "gpt-5.4", "gpt-5-mini" }, + models); + } + // --- PrettifyModel tests --- // The prettifier is duplicated in ExpandedSessionView.razor and ModelSelector.razor. // We test the logic inline here to catch regressions like the "Opus-4.5" bug. diff --git a/PolyPilot.Tests/ScenarioReferenceTests.cs b/PolyPilot.Tests/ScenarioReferenceTests.cs index 48eb93e2c6..029cdbe9f1 100644 --- a/PolyPilot.Tests/ScenarioReferenceTests.cs +++ b/PolyPilot.Tests/ScenarioReferenceTests.cs @@ -153,13 +153,14 @@ public void Scenario_RefreshSessionsButton_HasUnitTestCoverage() /// /// Scenario: "create-session-model-picker-includes-gpt-5-4" - /// Unit test equivalents: ModelSelectionTests.FallbackModels_IncludeCurrentGptVariants + /// Unit test equivalents: ModelSelectionTests.BuildSelectionList_AppendsSelectionAndDefault_WhenDiscoveryIsEmpty, + /// ModelSelectionTests.BuildSelectionList_NormalizesDiscoveredModels_AndAvoidsDuplicates, /// and EventsJsonlParsingTests.ExtractLatestModelFromEvents_LaterModelChangeWins /// [Fact] public void Scenario_ModelPickerIncludesGpt54_HasUnitTestCoverage() { - Assert.True(true, "See ModelSelectionTests.FallbackModels_IncludeCurrentGptVariants and EventsJsonlParsingTests.ExtractLatestModelFromEvents_LaterModelChangeWins"); + Assert.True(true, "See ModelSelectionTests.BuildSelectionList_* and EventsJsonlParsingTests.ExtractLatestModelFromEvents_LaterModelChangeWins"); } /// diff --git a/PolyPilot.Tests/Scenarios/mode-switch-scenarios.json b/PolyPilot.Tests/Scenarios/mode-switch-scenarios.json index c5a215edf6..24ec433201 100644 --- a/PolyPilot.Tests/Scenarios/mode-switch-scenarios.json +++ b/PolyPilot.Tests/Scenarios/mode-switch-scenarios.json @@ -530,9 +530,10 @@ { "id": "create-session-model-picker-includes-gpt-5-4", "name": "Create session model picker exposes GPT-5.4", - "description": "The create-session model selector should list GPT-5.4 so users can match the CLI even when the app falls back to its built-in model catalog.", + "description": "The create-session model selector should list GPT-5.4 from the Copilot CLI's discovered model list, without requiring PolyPilot to hardcode every supported model.", "unitTestCoverage": [ - "ModelSelectionTests.FallbackModels_IncludeCurrentGptVariants" + "ModelSelectionTests.BuildSelectionList_AppendsSelectionAndDefault_WhenDiscoveryIsEmpty", + "ModelSelectionTests.BuildSelectionList_NormalizesDiscoveredModels_AndAvoidsDuplicates" ], "steps": [ { @@ -1585,4 +1586,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/PolyPilot.Tests/StateChangeCoalescerTests.cs b/PolyPilot.Tests/StateChangeCoalescerTests.cs index 64185598a6..55ae64ec08 100644 --- a/PolyPilot.Tests/StateChangeCoalescerTests.cs +++ b/PolyPilot.Tests/StateChangeCoalescerTests.cs @@ -70,19 +70,31 @@ public async Task SeparateBursts_FireSeparately() { var svc = CreateService(); int fireCount = 0; - svc.OnStateChanged += () => Interlocked.Increment(ref fireCount); + var firstBurstFired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondBurstFired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + svc.OnStateChanged += () => + { + var count = Interlocked.Increment(ref fireCount); + if (count >= 1) + firstBurstFired.TrySetResult(); + if (count >= 2) + secondBurstFired.TrySetResult(); + }; // First burst for (int i = 0; i < 10; i++) svc.NotifyStateChangedCoalesced(); - // Wait well beyond the coalesce window (150ms) to ensure the timer fires, - // even under heavy CI/GC load. Previous 300ms was flaky under load. - await Task.Delay(800); + var firstBurstCompleted = await Task.WhenAny(firstBurstFired.Task, Task.Delay(5000)); + Assert.Same(firstBurstFired.Task, firstBurstCompleted); - // Second burst after timer has fired + // Second burst after the first coalesced notification has actually fired for (int i = 0; i < 10; i++) svc.NotifyStateChangedCoalesced(); - await Task.Delay(800); + var secondBurstCompleted = await Task.WhenAny(secondBurstFired.Task, Task.Delay(5000)); + Assert.Same(secondBurstFired.Task, secondBurstCompleted); + + // Small settle window for any extra coalesced fires. + await Task.Delay(100); // Each burst should produce ~1 notification Assert.InRange(fireCount, 2, 4); diff --git a/PolyPilot/Components/Layout/SessionListItem.razor b/PolyPilot/Components/Layout/SessionListItem.razor index 15389c6f4e..79df0e8cad 100644 --- a/PolyPilot/Components/Layout/SessionListItem.razor +++ b/PolyPilot/Components/Layout/SessionListItem.razor @@ -191,7 +191,7 @@