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">