Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9f3e477
fix: model selection for 1M context variant + mobile model list sync
PureWeen Mar 31, 2026
9a9d421
feat: add all sessions to @ mention autocomplete
PureWeen Mar 31, 2026
1af2b0f
feat: cross-session @mention routing in SendFromCard
PureWeen Mar 31, 2026
209873d
fix: route @mention anywhere in message, not just at start
PureWeen Mar 31, 2026
2d8ce5a
docs: add relaunch.sh safety rules to maui-ai-debugging skill
PureWeen Mar 31, 2026
d2f1677
feat: use contains matching for @ mention autocomplete
PureWeen Mar 31, 2026
f6d1758
docs: rewrite relaunch rules with consensus from 3 agent review
PureWeen Mar 31, 2026
ebc265e
revert: remove relaunch rules from maui-ai-debugging skill
PureWeen Mar 31, 2026
2c1f142
fix: skip eager resume for actively-processing sessions after relaunch
PureWeen Mar 31, 2026
123e123
fix: eager-resume actively-processing sessions so events flow after r…
PureWeen Apr 1, 2026
39e6bee
fix: address PR #460 review findings
PureWeen Apr 1, 2026
fc217a0
fix: poll events.jsonl instead of eager-resume for active sessions
PureWeen Apr 1, 2026
3cd00c4
fix: add generation guard and IsMultiAgentSession to poll-then-resume
PureWeen Apr 1, 2026
d73cd14
fix: detect CLI completion via file-size stability instead of 600s st…
PureWeen Apr 1, 2026
394c78d
fix: skip file-size stability when tool is actively executing
PureWeen Apr 1, 2026
9c2fa62
fix: remove file-size stability check — only terminal events are reli…
PureWeen Apr 1, 2026
9174d5b
fix: marshal history merge to UI thread in poller (thread safety)
PureWeen Apr 1, 2026
143293c
fix: reset LastUpdatedAt on restore so UI shows fresh activity time
PureWeen Apr 1, 2026
9c5a9f7
fix: eliminate flaky tests by fixing timer callback races
PureWeen Apr 1, 2026
1456f98
fix: prevent model reset to Haiku after abort/reconnect
PureWeen Apr 1, 2026
2d97842
feat: natural-language @mention routing — interpret routing instructions
PureWeen Apr 1, 2026
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
18 changes: 11 additions & 7 deletions PolyPilot.Tests/EventsJsonlParsingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
77 changes: 74 additions & 3 deletions PolyPilot.Tests/ModelSelectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

/// <summary>
/// Mirror of the PrettifyModel logic from ExpandedSessionView.razor / ModelSelector.razor.
/// Mirror of the PrettifyModel logic for tests that don't pass a displayNames dictionary.
/// </summary>
private static string PrettifyModel(string modelId)
{
Expand Down Expand Up @@ -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<string, string>
{
["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<string> { "claude-opus-4.6", "claude-opus-4.6-1m" },
ModelDisplayNames = new Dictionary<string, string>
{
["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"));
}
}
1 change: 1 addition & 0 deletions PolyPilot.Tests/PolyPilot.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<Compile Include="../PolyPilot/Models/SessionOrganization.cs" Link="Shared/SessionOrganization.cs" />
<Compile Include="../PolyPilot/Models/FiestaModels.cs" Link="Shared/FiestaModels.cs" />
<Compile Include="../PolyPilot/Models/ModelHelper.cs" Link="Shared/ModelHelper.cs" />
<Compile Include="../PolyPilot/Models/RoutingHelper.cs" Link="Shared/RoutingHelper.cs" />
<Compile Include="../PolyPilot/Models/CommandHistory.cs" Link="Shared/CommandHistory.cs" />
<Compile Include="../PolyPilot/Models/RepositoryInfo.cs" Link="Shared/RepositoryInfo.cs" />
<Compile Include="../PolyPilot/Models/RenderThrottle.cs" Link="Shared/RenderThrottle.cs" />
Expand Down
179 changes: 179 additions & 0 deletions PolyPilot.Tests/RoutingHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
using PolyPilot.Models;

namespace PolyPilot.Tests;

/// <summary>
/// Tests for RoutingHelper.ExtractForwardedContent — the natural-language routing
/// extraction that enables "please tell @Session that X" → forwards "X" to @Session.
/// </summary>
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);
}
}
31 changes: 22 additions & 9 deletions PolyPilot.Tests/StateChangeCoalescerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
14 changes: 8 additions & 6 deletions PolyPilot.Tests/StuckSessionRecoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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"));
Expand All @@ -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
{
Expand Down
Loading