Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
180 changes: 180 additions & 0 deletions PolyPilot.Tests/ModelSelectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -361,4 +361,184 @@ 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<string, string>
{
{ "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));
}
}

// --- 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.

/// <summary>
/// Mirror of the PrettifyModel logic from ExpandedSessionView.razor / ModelSelector.razor.
/// </summary>
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);
}
}
23 changes: 19 additions & 4 deletions PolyPilot/Components/ExpandedSessionView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<div class="info-popover">
<span class="info-trigger">ℹ︎</span>
<div class="info-panel">
<div class="info-row"><span class="info-label">Model</span><span class="info-value">@CurrentModel</span></div>
<div class="info-row"><span class="info-label">Model</span><span class="info-value">@PrettifyModel(CurrentModel)</span></div>
@if (!string.IsNullOrEmpty(Session.WorkingDirectory))
{
<div class="info-row"><span class="info-label">Directory</span><span class="info-value">@Session.WorkingDirectory</span></div>
Expand Down Expand Up @@ -133,14 +133,14 @@
<button class="mode-btn @(PlanMode ? "active" : "")" @onclick="() => OnSetPlanMode.InvokeAsync(true)">Plan</button>
</div>
<span class="status-sep">·</span>
<select class="inline-model-select" value="@CurrentModel" @onchange="e => OnSetModel.InvokeAsync(e.Value?.ToString())" disabled="@(Session.History.Count > 0)" title="@(Session.History.Count > 0 ? "Model cannot be changed during an active session" : "Select model")">
<select class="inline-model-select" value="@CurrentModel" @onchange="e => OnSetModel.InvokeAsync(e.Value?.ToString())" disabled="@Session.IsProcessing" title="@(Session.IsProcessing ? "Wait for response to complete" : "Switch model")">
@if (!AvailableModels.Contains(CurrentModel))
{
<option value="@CurrentModel">@CurrentModel</option>
<option value="@CurrentModel">@PrettifyModel(CurrentModel)</option>
}
@foreach (var m in AvailableModels)
{
<option value="@m">@m</option>
<option value="@m">@PrettifyModel(m)</option>
}
</select>
<span class="status-extra">
Expand Down Expand Up @@ -214,6 +214,21 @@

private void LoadMore() => OnLoadMore.InvokeAsync();

private static 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));
}

private void OpenSessionFolder(string sessionId)
{
try
Expand Down
6 changes: 5 additions & 1 deletion PolyPilot/Components/ModelSelector.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions PolyPilot/Components/Pages/Dashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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;
Expand Down
58 changes: 58 additions & 0 deletions PolyPilot/Services/CopilotService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
/// 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.
/// </summary>
public async Task<bool> 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<string> SendPromptAsync(string sessionName, string prompt, List<string>? imagePaths = null, CancellationToken cancellationToken = default)
{
// In demo mode, simulate a response locally
Expand Down