From 5fb36f4571860af36e17d992522f7f307dba4ea5 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 13 Feb 2026 15:38:25 -0600 Subject: [PATCH 1/4] Enable model switching for active sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ChangeModelAsync to CopilotService that switches the model of a running session by disposing the current SDK connection and resuming with a new ResumeSessionConfig.Model. The session ID and history are preserved server-side — only the backend model changes. - Enable the model dropdown for active sessions (was previously disabled when history > 0). Now only disabled while processing. - Update SetExpandedModel in Dashboard to call ChangeModelAsync for sessions with history. - ChangeModelAsync guards: no-op if already on the requested model, blocks during processing, normalizes model slug. --- .../Components/ExpandedSessionView.razor | 2 +- PolyPilot/Components/Pages/Dashboard.razor | 10 +++- PolyPilot/Services/CopilotService.cs | 58 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index 1eab5282f1..90dfc6d77f 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -133,7 +133,7 @@ · - @if (!AvailableModels.Contains(CurrentModel)) { diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index b81dfb1310..1ff98acea7 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -1868,6 +1868,7 @@ private async Task SetExpandedModel(AgentSessionInfo session, string? model) { if (string.IsNullOrEmpty(model)) return; + // If the session has no history, recreate it to "switch" the model effectively if (session.History.Count == 0) { @@ -1877,8 +1878,13 @@ return; } - // SDK doesn't support changing model mid-session; update local state only. - session.Model = model; + // For active sessions with history, resume with the new model + var success = await CopilotService.ChangeModelAsync(session.Name, model); + if (success) + { + sessions = CopilotService.GetAllSessions().ToList(); + await InvokeAsync(SafeRefreshAsync); + } } private void SetPlanMode(string sessionName, bool enabled) => planModeBySession[sessionName] = enabled; diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index be424b7d1e..e62d8f3aa8 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1066,6 +1066,64 @@ ALWAYS run the relaunch script as the final step after making changes to this pr return await CreateSessionAsync(name, newModel, workingDir); } + /// + /// Switch the model for an active session by resuming it with a new ResumeSessionConfig. + /// The session history is preserved server-side (same session ID); we just reconnect + /// asking for a different model. + /// + public async Task ChangeModelAsync(string sessionName, string newModel, CancellationToken cancellationToken = default) + { + 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; + + // Already on this model — no-op + if (state.Info.Model == normalizedModel) return true; + + Debug($"Switching model for '{sessionName}': {state.Info.Model} → {normalizedModel}"); + + try + { + // Dispose old session connection + await state.Session.DisposeAsync(); + + if (_client == null) + throw new InvalidOperationException("Client is not initialized"); + + // Resume the same session ID with the new model + var resumeConfig = new ResumeSessionConfig + { + Model = normalizedModel, + WorkingDirectory = state.Info.WorkingDirectory + }; + var newSession = await _client.ResumeSessionAsync(state.Info.SessionId, resumeConfig, cancellationToken); + + // Build replacement state, preserving info/history + state.Info.Model = normalizedModel; + var newState = new SessionState + { + Session = newSession, + Info = state.Info + }; + newSession.On(evt => HandleSessionEvent(newState, evt)); + _sessions[sessionName] = newState; + + Debug($"Model switched for '{sessionName}' to {normalizedModel}"); + SaveActiveSessionsToDisk(); + OnStateChanged?.Invoke(); + return true; + } + catch (Exception ex) + { + Debug($"Failed to switch model for '{sessionName}': {ex.Message}"); + OnError?.Invoke(sessionName, $"Failed to switch model: {ex.Message}"); + return false; + } + } + public async Task SendPromptAsync(string sessionName, string prompt, List? imagePaths = null, CancellationToken cancellationToken = default) { // In demo mode, simulate a response locally From 4d2df97446454e48725954f27cd25b1c637e68d7 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 13 Feb 2026 15:40:09 -0600 Subject: [PATCH 2/4] Add tests for ChangeModelAsync behavior --- PolyPilot.Tests/ModelSelectionTests.cs | 94 ++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/PolyPilot.Tests/ModelSelectionTests.cs b/PolyPilot.Tests/ModelSelectionTests.cs index 4dfbb615e9..904fe45d40 100644 --- a/PolyPilot.Tests/ModelSelectionTests.cs +++ b/PolyPilot.Tests/ModelSelectionTests.cs @@ -361,4 +361,98 @@ public void EndToEnd_LegacyDisplayNameEntry_IsNormalizedOnResume() Assert.Equal("claude-opus-4.5", resumeModel); Assert.Equal("/some/worktree", entry.WorkingDirectory); } + + // === ChangeModelAsync behavior tests === + + [Fact] + public void ChangeModel_NewModelIsNormalized() + { + // ChangeModelAsync normalizes before use — display names from UI must become slugs + var userSelection = "Claude Opus 4.6"; + var normalized = ModelHelper.NormalizeToSlug(userSelection); + Assert.Equal("claude-opus-4.6", normalized); + } + + [Fact] + public void ChangeModel_SameModel_IsNoOp() + { + // If current model == requested model, ChangeModelAsync should be a no-op + var currentModel = "claude-opus-4.5"; + var requested = "claude-opus-4.5"; + var normalizedRequested = ModelHelper.NormalizeToSlug(requested); + Assert.Equal(currentModel, normalizedRequested); // Would trigger no-op guard + } + + [Fact] + public void ChangeModel_DifferentModel_UpdatesInfo() + { + // Simulates what ChangeModelAsync does to AgentSessionInfo after successful resume + var info = new AgentSessionInfo + { + Name = "TestSession", + Model = "claude-sonnet-4.5", + SessionId = "some-guid", + WorkingDirectory = "/some/worktree" + }; + + var newModel = ModelHelper.NormalizeToSlug("claude-opus-4.6"); + Assert.NotEqual(info.Model, newModel); // Different model → would proceed + + // After successful resume, model is updated + info.Model = newModel; + Assert.Equal("claude-opus-4.6", info.Model); + + // Working directory must NOT change during model switch + Assert.Equal("/some/worktree", info.WorkingDirectory); + } + + [Fact] + public void ChangeModel_PreservesSessionIdentity() + { + // Model switch reconnects the same session ID — history is preserved server-side + var info = new AgentSessionInfo + { + Name = "MySession", + Model = "gpt-5.1-codex", + SessionId = "fixed-guid-123", + WorkingDirectory = "/my/worktree" + }; + info.History.Add(ChatMessage.UserMessage("hello")); + info.History.Add(new ChatMessage("assistant", "hi there", DateTime.Now)); + + var originalSessionId = info.SessionId; + var originalHistory = info.History.Count; + var originalName = info.Name; + var originalWorkDir = info.WorkingDirectory; + + // Simulate model switch + info.Model = ModelHelper.NormalizeToSlug("claude-opus-4.6"); + + // Everything except model must be unchanged + Assert.Equal(originalSessionId, info.SessionId); + Assert.Equal(originalHistory, info.History.Count); + Assert.Equal(originalName, info.Name); + Assert.Equal(originalWorkDir, info.WorkingDirectory); + Assert.Equal("claude-opus-4.6", info.Model); + } + + [Fact] + public void ChangeModel_DisplayNameFromDropdown_NormalizesToSlug() + { + // The UI dropdown shows display names but passes slugs. + // If somehow a display name gets through, ChangeModelAsync must normalize it. + var displayNames = new Dictionary + { + { "Claude Opus 4.6 (fast mode)", "claude-opus-4.6-fast" }, + { "Gemini 3 Pro (Preview)", "gemini-3-pro-preview" }, + { "GPT-5.1-Codex-Max", "gpt-5.1-codex-max" }, + }; + + foreach (var (display, expectedSlug) in displayNames) + { + var normalized = ModelHelper.NormalizeToSlug(display); + Assert.Equal(expectedSlug, normalized); + Assert.False(ModelHelper.IsDisplayName(normalized)); + } + } } From 3cb07db3271c981c0c99a09c7f1a25ab530e104a Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 13 Feb 2026 15:43:31 -0600 Subject: [PATCH 3/4] Prettify model names in expanded session dropdown Show display names (e.g. 'Claude Opus 4.5') instead of raw slugs in the inline model selector and info panel, matching the ModelSelector component used in the sidebar. --- PolyPilot/Components/ExpandedSessionView.razor | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index 90dfc6d77f..0f1eac17bf 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -19,7 +19,7 @@
ℹ︎
-
Model@CurrentModel
+
Model@PrettifyModel(CurrentModel)
@if (!string.IsNullOrEmpty(Session.WorkingDirectory)) {
Directory@Session.WorkingDirectory
@@ -136,11 +136,11 @@ @@ -214,6 +214,16 @@ private void LoadMore() => OnLoadMore.InvokeAsync(); + private static string PrettifyModel(string modelId) + { + var display = modelId + .Replace("claude-", "Claude ") + .Replace("gpt-", "GPT-") + .Replace("gemini-", "Gemini "); + return string.Join(' ', display.Split(' ').Select(s => + s.Length > 0 ? char.ToUpper(s[0]) + s[1..] : s)); + } + private void OpenSessionFolder(string sessionId) { try From 89b81db8bc12135d100c26ed1326120832f9ac05 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Fri, 13 Feb 2026 15:48:23 -0600 Subject: [PATCH 4/4] =?UTF-8?q?Fix=20prettifier=20hyphens=20(Opus-4.5=20?= =?UTF-8?q?=E2=86=92=20Opus=204.5)=20and=20add=2018=20prettifier=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PrettifyModel now replaces all remaining hyphens with spaces (except GPT- prefix) - Added tests for prettify output, no-duplicate-hyphens, round-trip with NormalizeToSlug - Fixed in both ExpandedSessionView.razor and ModelSelector.razor --- PolyPilot.Tests/ModelSelectionTests.cs | 86 +++++++++++++++++++ .../Components/ExpandedSessionView.razor | 7 +- PolyPilot/Components/ModelSelector.razor | 6 +- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/PolyPilot.Tests/ModelSelectionTests.cs b/PolyPilot.Tests/ModelSelectionTests.cs index 904fe45d40..bbe79a1135 100644 --- a/PolyPilot.Tests/ModelSelectionTests.cs +++ b/PolyPilot.Tests/ModelSelectionTests.cs @@ -455,4 +455,90 @@ public void ChangeModel_DisplayNameFromDropdown_NormalizesToSlug() Assert.False(ModelHelper.IsDisplayName(normalized)); } } + + // --- 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. + + /// + /// Mirror of the PrettifyModel logic from ExpandedSessionView.razor / ModelSelector.razor. + /// + private static string PrettifyModel(string modelId) + { + var display = modelId + .Replace("claude-", "Claude ") + .Replace("gpt-", "GPT-") + .Replace("gemini-", "Gemini "); + display = display.Replace("-", " "); + display = display.Replace("GPT ", "GPT-"); + return string.Join(' ', display.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(s => + s.Length > 0 ? char.ToUpper(s[0]) + s[1..] : s)); + } + + [Theory] + [InlineData("claude-opus-4.5", "Claude Opus 4.5")] + [InlineData("claude-opus-4.6-fast", "Claude Opus 4.6 Fast")] + [InlineData("claude-sonnet-4.5", "Claude Sonnet 4.5")] + [InlineData("claude-haiku-4.5", "Claude Haiku 4.5")] + [InlineData("gpt-5.1-codex-max", "GPT-5.1 Codex Max")] + [InlineData("gpt-5.1-codex-mini", "GPT-5.1 Codex Mini")] + [InlineData("gpt-5.2-codex", "GPT-5.2 Codex")] + [InlineData("gpt-5-mini", "GPT-5 Mini")] + [InlineData("gemini-3-pro-preview", "Gemini 3 Pro Preview")] + public void PrettifyModel_ProducesReadableNames(string slug, string expected) + { + Assert.Equal(expected, PrettifyModel(slug)); + } + + [Theory] + [InlineData("claude-opus-4.5")] + [InlineData("gpt-5.1-codex")] + [InlineData("gemini-3-pro-preview")] + public void PrettifyModel_NoDuplicateHyphens(string slug) + { + var pretty = PrettifyModel(slug); + // Should not contain stray hyphens (except GPT- prefix) + var withoutGpt = pretty.Replace("GPT-", "GPT"); + Assert.DoesNotContain("-", withoutGpt); + } + + [Fact] + public void PrettifyModel_DoesNotProduceDuplicateEntries() + { + // Regression: "claude-opus-4.5" was prettified to "Claude Opus-4.5" + // which didn't match the display name "Claude Opus 4.5", causing duplicates in the dropdown + var slugs = new[] { "claude-opus-4.5", "claude-sonnet-4.5", "gpt-5.1-codex-max" }; + foreach (var slug in slugs) + { + var pretty = PrettifyModel(slug); + Assert.DoesNotContain("-", pretty.Replace("GPT-", "GPT")); + // Prettifying twice should be stable + Assert.Equal(pretty, PrettifyModel(slug)); + } + } + + [Fact] + public void RecreateSession_UsesNormalizedModel() + { + // When RecreateSessionAsync is called, the model should be normalized + // before being passed to CreateSessionAsync + var displayName = "Claude Opus 4.5"; + var normalized = ModelHelper.NormalizeToSlug(displayName); + Assert.Equal("claude-opus-4.5", normalized); + // The recreated session should use the slug, not the display name + Assert.False(ModelHelper.IsDisplayName(normalized)); + } + + [Theory] + [InlineData("claude-opus-4.5")] + [InlineData("gpt-5.1-codex-max")] + [InlineData("gemini-3-pro-preview")] + [InlineData("claude-opus-4.6-fast")] + public void RoundTrip_NormalizeAndPrettify_AreConsistent(string slug) + { + // Prettify a slug, then normalize back — should return the original slug + var pretty = PrettifyModel(slug); + var backToSlug = ModelHelper.NormalizeToSlug(pretty); + Assert.Equal(slug, backToSlug); + } } diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index 0f1eac17bf..ea5fe12baf 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -220,7 +220,12 @@ .Replace("claude-", "Claude ") .Replace("gpt-", "GPT-") .Replace("gemini-", "Gemini "); - return string.Join(' ', display.Split(' ').Select(s => + // 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)); } diff --git a/PolyPilot/Components/ModelSelector.razor b/PolyPilot/Components/ModelSelector.razor index 961b96814e..431277f392 100644 --- a/PolyPilot/Components/ModelSelector.razor +++ b/PolyPilot/Components/ModelSelector.razor @@ -73,9 +73,13 @@ .Replace("claude-", "Claude ") .Replace("gpt-", "GPT-") .Replace("gemini-", "Gemini "); + // Replace remaining hyphens with spaces (e.g. "opus-4.5" → "opus 4.5") + display = display.Replace("-", " "); + // Re-insert hyphen for GPT prefix + display = display.Replace("GPT ", "GPT-"); // Capitalize segments - display = string.Join(' ', display.Split(' ').Select(s => + display = string.Join(' ', display.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Length > 0 ? char.ToUpper(s[0]) + s[1..] : s)); return new ModelDisplayInfo(display, icon);