From 9f3e477a205d48c4d0a16b0b24c486b4fa94698f Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Tue, 31 Mar 2026 00:22:24 -0500 Subject: [PATCH 01/21] fix: model selection for 1M context variant + mobile model list sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes: 1. FetchAvailableModelsAsync used SDK Model.Name (display name) instead of Model.Id (slug). When user selected 'Claude Opus 4.6 (1M Context)(Internal Only)', NormalizeToSlug stripped the parenthetical to 'claude-opus-4.6' — the wrong model. 2. NormalizeToSlug only handled single parenthetical groups. Multiple parens like '(1M Context)(Internal Only)' were parsed incorrectly. 3. Bridge protocol never sent ModelDisplayNames to mobile clients. Fixes: - FetchAvailableModelsAsync now stores Model.Id (slug) and builds a ModelDisplayNames dictionary mapping Id→Name for rich display - NormalizeToSlug properly parses multiple parenthetical groups and recognizes '1m' as a known suffix - Added ModelDisplayNames to SessionsListPayload bridge protocol - WsBridgeClient/Server sync AvailableModels + ModelDisplayNames - Bridge.cs OnStateChanged syncs model list to CopilotService in remote mode - Added PrettifyModel to ModelHelper (centralized from 3 Razor files) - Added claude-opus-4.6-1m to FallbackModels - ModelSelector/ExpandedSessionView/CreateSessionForm accept DisplayNames param - SessionListItem renders display names via ModelHelper.PrettifyModel - 8 new tests for 1M model handling, display name lookup, bridge payload Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ModelSelectionTests.cs | 77 ++++++++++++++++++- PolyPilot.Tests/TestStubs.cs | 1 + .../Components/ExpandedSessionView.razor | 15 +--- .../Components/Layout/CreateSessionForm.razor | 3 +- .../Components/Layout/SessionListItem.razor | 3 +- .../Components/Layout/SessionSidebar.razor | 2 + PolyPilot/Components/ModelSelector.razor | 14 +--- PolyPilot/Components/Pages/Dashboard.razor | 1 + PolyPilot/Models/BridgeMessages.cs | 4 +- PolyPilot/Models/ModelHelper.cs | 25 ++++++ PolyPilot/Services/CopilotService.Bridge.cs | 5 ++ .../Services/CopilotService.Utilities.cs | 28 +++++-- PolyPilot/Services/CopilotService.cs | 11 +-- PolyPilot/Services/IWsBridgeClient.cs | 1 + PolyPilot/Services/WsBridgeClient.cs | 7 +- PolyPilot/Services/WsBridgeServer.cs | 1 + 16 files changed, 154 insertions(+), 44 deletions(-) diff --git a/PolyPilot.Tests/ModelSelectionTests.cs b/PolyPilot.Tests/ModelSelectionTests.cs index 4f4a8d00e5..038c086b92 100644 --- a/PolyPilot.Tests/ModelSelectionTests.cs +++ b/PolyPilot.Tests/ModelSelectionTests.cs @@ -494,11 +494,10 @@ public void ChangeModel_DisplayNameFromDropdown_NormalizesToSlug() } // --- 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. + // Now centralized in ModelHelper.PrettifyModel. Tests use the real method. /// - /// Mirror of the PrettifyModel logic from ExpandedSessionView.razor / ModelSelector.razor. + /// Mirror of the PrettifyModel logic for tests that don't pass a displayNames dictionary. /// private static string PrettifyModel(string modelId) { @@ -578,4 +577,76 @@ public void RoundTrip_NormalizeAndPrettify_AreConsistent(string slug) var backToSlug = ModelHelper.NormalizeToSlug(pretty); Assert.Equal(slug, backToSlug); } + + [Fact] + public void PrettifyModel_UsesDisplayNamesWhenAvailable() + { + var displayNames = new Dictionary + { + ["claude-opus-4.6-1m"] = "Claude Opus 4.6 (1M Context)(Internal Only)", + ["claude-opus-4.6"] = "Claude Opus 4.6", + }; + Assert.Equal("Claude Opus 4.6 (1M Context)(Internal Only)", + ModelHelper.PrettifyModel("claude-opus-4.6-1m", displayNames)); + Assert.Equal("Claude Opus 4.6", + ModelHelper.PrettifyModel("claude-opus-4.6", displayNames)); + // Falls back to algorithmic when not in dictionary + Assert.Equal("Claude Sonnet 4.5", + ModelHelper.PrettifyModel("claude-sonnet-4.5", displayNames)); + } + + // --- 1M Context model tests (regression for Issue: model doesn't stay selected) --- + + [Fact] + public void NormalizeToSlug_1mContext_PreservesSuffix() + { + // The core bug: "Claude Opus 4.6 (1M Context)(Internal Only)" was + // being normalized to "claude-opus-4.6" instead of "claude-opus-4.6-1m" + var displayName = "Claude Opus 4.6 (1M Context)(Internal Only)"; + var slug = ModelHelper.NormalizeToSlug(displayName); + Assert.Equal("claude-opus-4.6-1m", slug); + } + + [Theory] + [InlineData("Claude Opus 4.6 (1M Context)", "claude-opus-4.6-1m")] + [InlineData("Claude Opus 4.6 (1M context)(Internal Only)", "claude-opus-4.6-1m")] + [InlineData("claude-opus-4.6-1m", "claude-opus-4.6-1m")] + public void NormalizeToSlug_1mVariants_AllResolveCorrectly(string input, string expected) + { + Assert.Equal(expected, ModelHelper.NormalizeToSlug(input)); + } + + [Fact] + public void NormalizeToSlug_MultipleParentheses_ParsedCorrectly() + { + // Handles multiple parenthetical groups independently + var input = "Claude Opus 4.6 (1M Context)(Internal Only)"; + var slug = ModelHelper.NormalizeToSlug(input); + // "1M Context" → "-1m", "Internal Only" → ignored + Assert.Equal("claude-opus-4.6-1m", slug); + Assert.NotEqual("claude-opus-4.6", slug); // The old broken behavior + } + + [Fact] + public void FallbackModels_Includes1mModel() + { + Assert.Contains("claude-opus-4.6-1m", ModelHelper.FallbackModels); + } + + [Fact] + public void SessionsListPayload_IncludesModelFields() + { + var payload = new SessionsListPayload + { + AvailableModels = new List { "claude-opus-4.6", "claude-opus-4.6-1m" }, + ModelDisplayNames = new Dictionary + { + ["claude-opus-4.6-1m"] = "Claude Opus 4.6 (1M Context)(Internal Only)" + } + }; + Assert.NotNull(payload.AvailableModels); + Assert.Equal(2, payload.AvailableModels.Count); + Assert.Contains("claude-opus-4.6-1m", payload.AvailableModels); + Assert.True(payload.ModelDisplayNames!.ContainsKey("claude-opus-4.6-1m")); + } } diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index 7a24317f2e..1ec4443518 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -73,6 +73,7 @@ internal class StubWsBridgeClient : IWsBridgeClient public string? GitHubLogin { get; set; } public string? ServerMachineName { get; set; } public List AvailableModels { get; set; } = new(); + public Dictionary ModelDisplayNames { get; set; } = new(); public event Action? OnStateChanged; public event Action? OnContentReceived; diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index 019330b80e..16e37bce69 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -339,6 +339,7 @@ [Parameter] public string InputMode { get; set; } = "chat"; [Parameter] public string CurrentModel { get; set; } = ""; [Parameter] public IReadOnlyList AvailableModels { get; set; } = Array.Empty(); + [Parameter] public IReadOnlyDictionary? ModelDisplayNames { get; set; } [Parameter] public List? PendingImages { get; set; } [Parameter] public string? UserAvatarUrl { get; set; } [Parameter] public string? RepoUrl { get; set; } @@ -545,19 +546,9 @@ private void LoadMore() => OnLoadMore.InvokeAsync(Session.Name); private void LoadFullHistory() => OnLoadFullHistory.InvokeAsync(Session.Name); - private static string PrettifyModel(string modelId) + private string PrettifyModel(string modelId) { - var display = modelId - .Replace("claude-", "Claude ") - .Replace("gpt-", "GPT-") - .Replace("gemini-", "Gemini "); - // Replace remaining hyphens with spaces (e.g. "opus-4.5" → "opus 4.5") - // but preserve hyphens within the GPT- prefix (already handled above) - display = display.Replace("-", " "); - // Re-insert hyphen for GPT prefix - display = display.Replace("GPT ", "GPT-"); - return string.Join(' ', display.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(s => - s.Length > 0 ? char.ToUpper(s[0]) + s[1..] : s)); + return Models.ModelHelper.PrettifyModel(modelId, ModelDisplayNames); } private void OpenSessionFolder(string sessionId) diff --git a/PolyPilot/Components/Layout/CreateSessionForm.razor b/PolyPilot/Components/Layout/CreateSessionForm.razor index 2a58b4dcfc..0a4e3fc45e 100644 --- a/PolyPilot/Components/Layout/CreateSessionForm.razor +++ b/PolyPilot/Components/Layout/CreateSessionForm.razor @@ -164,7 +164,7 @@ else class="ns-prompt" rows="2">