diff --git a/PolyPilot.Tests/EventsJsonlParsingTests.cs b/PolyPilot.Tests/EventsJsonlParsingTests.cs
index ed96e80012..077b630eed 100644
--- a/PolyPilot.Tests/EventsJsonlParsingTests.cs
+++ b/PolyPilot.Tests/EventsJsonlParsingTests.cs
@@ -87,23 +87,27 @@ public void TitleCleaning_RemovesNewlines()
[Fact]
public void IsSessionStillProcessing_ActiveEventTypes()
{
+ // Terminal events are the only ones that indicate processing is complete.
+ // Everything else (including intermediate events like assistant.turn_end,
+ // assistant.message, tool.execution_end) means the session is still active.
+ var terminalEvents = new[] { "session.idle", "session.error", "session.shutdown" };
+
+ // These should indicate the session is still processing (not terminal)
var activeEvents = new[]
{
"assistant.turn_start", "tool.execution_start",
"tool.execution_progress", "assistant.message_delta",
"assistant.reasoning", "assistant.reasoning_delta",
- "assistant.intent"
+ "assistant.intent", "assistant.turn_end",
+ "assistant.message", "session.start"
};
-
- // These should indicate the session is still processing
foreach (var eventType in activeEvents)
{
- Assert.Contains(eventType, activeEvents);
+ Assert.DoesNotContain(eventType, terminalEvents);
}
- // These should NOT indicate processing
- var inactiveEvents = new[] { "session.idle", "assistant.message", "session.start" };
- foreach (var eventType in inactiveEvents)
+ // These SHOULD indicate processing is complete (terminal) — must not appear in activeEvents
+ foreach (var eventType in terminalEvents)
{
Assert.DoesNotContain(eventType, activeEvents);
}
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/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj
index 052f81c420..0bda4e79b0 100644
--- a/PolyPilot.Tests/PolyPilot.Tests.csproj
+++ b/PolyPilot.Tests/PolyPilot.Tests.csproj
@@ -36,6 +36,7 @@
+
diff --git a/PolyPilot.Tests/RoutingHelperTests.cs b/PolyPilot.Tests/RoutingHelperTests.cs
new file mode 100644
index 0000000000..4e890b6fc7
--- /dev/null
+++ b/PolyPilot.Tests/RoutingHelperTests.cs
@@ -0,0 +1,179 @@
+using PolyPilot.Models;
+
+namespace PolyPilot.Tests;
+
+///
+/// Tests for RoutingHelper.ExtractForwardedContent — the natural-language routing
+/// extraction that enables "please tell @Session that X" → forwards "X" to @Session.
+///
+public class RoutingHelperTests
+{
+ // ── Helper: call both args the same way we do in Dashboard.razor ─────────
+ private static string Extract(string strippedMessage, string originalPrompt)
+ => RoutingHelper.ExtractForwardedContent(strippedMessage, originalPrompt);
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Passthrough — messages that need no transformation
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("hello world", "hello world")]
+ [InlineData("please fix this bug", "please fix this bug")]
+ [InlineData("the tests are now passing", "the tests are now passing")]
+ public void PlainContent_ReturnsUnchanged(string stripped, string original)
+ => Assert.Equal(stripped, Extract(stripped, original));
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // "please tell @Session that X" → X
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void PleaseTell_ThatContent_ExtractsContent()
+ {
+ // "please tell @Bob that the tests pass" after @-strip → "please tell that the tests pass"
+ var result = Extract("please tell that the tests pass", "please tell @Bob that the tests pass");
+ Assert.Equal("the tests pass", result);
+ }
+
+ [Fact]
+ public void PleaseForward_ColonContent_ExtractsContent()
+ {
+ // "please forward to @Bob: hello world" after @-strip → "please forward to : hello world"
+ // after colon-cleanup → "hello world"
+ var stripped = "please forward to : hello world";
+ var result = Extract(stripped, "please forward to @Bob: hello world");
+ Assert.Equal("hello world", result);
+ }
+
+ [Fact]
+ public void CanYouSend_ThatContent_ExtractsContent()
+ {
+ var stripped = "can you send that the meeting is at 3pm";
+ var result = Extract(stripped, "can you send @Bob that the meeting is at 3pm");
+ Assert.Equal("the meeting is at 3pm", result);
+ }
+
+ [Fact]
+ public void CouldYouTell_ThatContent_ExtractsContent()
+ {
+ var stripped = "could you tell that everything is working";
+ var result = Extract(stripped, "could you tell @Session that everything is working");
+ Assert.Equal("everything is working", result);
+ }
+
+ [Fact]
+ public void PleaseSend_MessageContent_ExtractsContent()
+ {
+ var stripped = "please send a message to : hello there";
+ var result = Extract(stripped, "please send a message to @Session: hello there");
+ Assert.Equal("hello there", result);
+ }
+
+ [Fact]
+ public void PleaseAsk_SayingContent_ExtractsContent()
+ {
+ var stripped = "please ask saying can you fix the bug";
+ var result = Extract(stripped, "please ask @Session saying can you fix the bug");
+ Assert.Equal("can you fix the bug", result);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Fallback: empty content after stripping → return original minus @tokens
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void PleasePassThisInfoTo_NoContent_FallsBackToOriginal()
+ {
+ // After stripping @Session: "please pass this info to"
+ // No extractable content → fallback to original minus @mention
+ var stripped = "please pass this info to";
+ var original = "please pass this info to @Session";
+ var result = Extract(stripped, original);
+ // Fallback preserves the original routing instruction (target gets context)
+ Assert.Equal("please pass this info to", result);
+ }
+
+ [Fact]
+ public void PleaseForwardThis_NoContent_FallsBackToOriginal()
+ {
+ var stripped = "please forward this";
+ var original = "please forward this @Bob";
+ var result = Extract(stripped, original);
+ Assert.Equal("please forward this", result);
+ }
+
+ [Fact]
+ public void TellAbout_NoExplicitContent_FallsBackToOriginal()
+ {
+ // "tell @Session about it" → stripped: "tell about it"
+ // After routing prefix strip: "about it" → connector "about" strip → "it"
+ // "it" is too short (< 3 chars) → fallback to "tell about it"
+ var stripped = "tell about it";
+ var original = "tell @Session about it";
+ var result = Extract(stripped, original);
+ // "it" is only 2 chars so fallback fires → "tell about it"
+ Assert.Equal("tell about it", result);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Message BEFORE the @mention — already handled by @-strip, no change needed
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ContentBeforeMention_PassesThrough()
+ {
+ // "check this out @Session" → stripped: "check this out"
+ // No routing prefix → return as-is
+ var stripped = "check this out";
+ var result = Extract(stripped, "check this out @Session");
+ Assert.Equal("check this out", result);
+ }
+
+ [Fact]
+ public void HeyMention_Content_PassesThrough()
+ {
+ // "@Session please fix this bug" → stripped: "please fix this bug"
+ var stripped = "please fix this bug";
+ var result = Extract(stripped, "@Session please fix this bug");
+ Assert.Equal("please fix this bug", result);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Regression: plain routing phrases don't eat normal sentences
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void MessageStartingWithForward_NotARoutingInstruction_IsKept()
+ {
+ // "forward slash commands are important" should NOT be stripped
+ // because "slash" doesn't match the routing prefix pattern
+ var stripped = "forward slash commands are important";
+ var result = Extract(stripped, "forward slash commands are important @Session");
+ // "forward slash" doesn't match (next word after "forward" isn't recognized)
+ // so stripped → "forward slash commands are important"
+ Assert.Equal("forward slash commands are important", result);
+ }
+
+ [Fact]
+ public void MessageStartingWithAsk_Question_IsKept()
+ {
+ // "ask me anything" after strip → "ask me anything" — "me" doesn't trigger routing
+ var stripped = "ask me anything";
+ var result = Extract(stripped, "ask me anything @Session");
+ // "ask me" doesn't match the routing suffix (no "to/:" after) so it passes through
+ // (or gets partially stripped but enough remains)
+ Assert.True(result.Length >= 3, $"Result should be non-trivial, got: '{result}'");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Whitespace handling
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ExtraWhitespace_IsCollapsed()
+ {
+ var stripped = "please tell that hello world";
+ var result = Extract(stripped, "please tell @Bob that hello world");
+ Assert.Equal("hello world", result);
+ }
+}
diff --git a/PolyPilot.Tests/StateChangeCoalescerTests.cs b/PolyPilot.Tests/StateChangeCoalescerTests.cs
index 5f41e44e54..336afd0895 100644
--- a/PolyPilot.Tests/StateChangeCoalescerTests.cs
+++ b/PolyPilot.Tests/StateChangeCoalescerTests.cs
@@ -70,22 +70,35 @@ public async Task SeparateBursts_FireSeparately()
{
var svc = CreateService();
int fireCount = 0;
- svc.OnStateChanged += () => Interlocked.Increment(ref fireCount);
- // First burst
+ // First burst — wait via TCS so we don't depend on wall-clock timing.
+ // Under heavy parallel test load, fixed delays like 800ms can be shorter
+ // than the threadpool-delayed timer callback, causing the pending CAS flag
+ // to remain set when burst 2 starts — burst 2 then silently merges into burst 1
+ // and only one notification fires instead of two.
+ var tcs1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ svc.OnStateChanged += () =>
+ {
+ Interlocked.Increment(ref fireCount);
+ tcs1.TrySetResult();
+ };
+
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);
+ // Wait for first burst to actually fire (with generous 5s timeout)
+ await Task.WhenAny(tcs1.Task, Task.Delay(5000));
+ Assert.True(tcs1.Task.IsCompleted, "First burst should have fired within 5s");
- // Second burst after timer has fired
+ // Second burst after first has fired
+ var tcs2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ svc.OnStateChanged += () => tcs2.TrySetResult();
for (int i = 0; i < 10; i++)
svc.NotifyStateChangedCoalesced();
- await Task.Delay(800);
+ await Task.WhenAny(tcs2.Task, Task.Delay(5000));
+ Assert.True(tcs2.Task.IsCompleted, "Second burst should have fired within 5s");
- // Each burst should produce ~1 notification
- Assert.InRange(fireCount, 2, 4);
+ // Each burst produced at least one notification — total should be 2 or slightly more
+ Assert.InRange(fireCount, 2, 6);
}
[Fact]
diff --git a/PolyPilot.Tests/StuckSessionRecoveryTests.cs b/PolyPilot.Tests/StuckSessionRecoveryTests.cs
index bd82d41907..eab468943a 100644
--- a/PolyPilot.Tests/StuckSessionRecoveryTests.cs
+++ b/PolyPilot.Tests/StuckSessionRecoveryTests.cs
@@ -303,6 +303,10 @@ public void StalenessThreshold_UsesWatchdogToolExecutionTimeout()
[InlineData("assistant.reasoning")]
[InlineData("assistant.reasoning_delta")]
[InlineData("assistant.intent")]
+ [InlineData("assistant.turn_end")] // between tool rounds — still processing
+ [InlineData("assistant.message")] // mid-turn text — still processing
+ [InlineData("tool.execution_end")] // tool just completed — still processing
+ [InlineData("session.start")] // session just started — still processing
public void IsSessionStillProcessing_AllActiveEventTypes_ReturnTrue(string eventType)
{
var svc = CreateService();
@@ -326,11 +330,9 @@ public void IsSessionStillProcessing_AllActiveEventTypes_ReturnTrue(string event
[Theory]
[InlineData("session.idle")]
- [InlineData("assistant.message")]
- [InlineData("session.start")]
- [InlineData("assistant.turn_end")]
- [InlineData("tool.execution_end")]
- public void IsSessionStillProcessing_InactiveEventTypes_ReturnFalse(string eventType)
+ [InlineData("session.error")]
+ [InlineData("session.shutdown")]
+ public void IsSessionStillProcessing_TerminalEventTypes_ReturnFalse(string eventType)
{
var svc = CreateService();
var tmpDir = Path.Combine(Path.GetTempPath(), "polypilot-test-" + Guid.NewGuid().ToString("N"));
@@ -343,7 +345,7 @@ public void IsSessionStillProcessing_InactiveEventTypes_ReturnFalse(string event
{
File.WriteAllText(eventsFile, $$$"""{"type":"{{{eventType}}}","data":{}}""");
var result = svc.IsSessionStillProcessing(sessionId, tmpDir);
- Assert.False(result, $"Inactive event type '{eventType}' should not report still processing");
+ Assert.False(result, $"Terminal event type '{eventType}' should not report still processing");
}
finally
{
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..1d3b9c2c17 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; }
@@ -350,6 +351,7 @@
[Parameter] public int MessageWindowSize { get; set; } = 25;
[Parameter] public bool FiestaActive { get; set; }
[Parameter] public IReadOnlyList FiestaWorkers { get; set; } = Array.Empty();
+ [Parameter] public IReadOnlyList AllSessionNames { get; set; } = Array.Empty();
[Parameter] public EventCallback OnSend { get; set; }
[Parameter] public EventCallback OnStop { get; set; }
@@ -545,19 +547,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)
@@ -741,12 +733,18 @@
{
get
{
- var tokens = FiestaWorkers
+ var fiestaTokens = FiestaWorkers
.SelectMany(w => new[]
{
NormalizeMentionToken(w.Hostname),
NormalizeMentionToken(w.Name)
- })
+ });
+
+ var sessionTokens = AllSessionNames
+ .Where(n => !string.Equals(n, Session.Name, StringComparison.OrdinalIgnoreCase))
+ .Select(NormalizeMentionToken);
+
+ var tokens = fiestaTokens.Concat(sessionTokens)
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(t => t, StringComparer.OrdinalIgnoreCase);
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">