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
32 changes: 32 additions & 0 deletions PolyPilot.Tests/PlatformHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,36 @@ public void AvailableModes_OnDesktop_HasAllThreeModes()
Assert.Contains(ConnectionMode.Remote, PlatformHelper.AvailableModes);
}
}

[Fact]
public void ShellEscape_PlainText_WrapsSingleQuotes()
{
Assert.Equal("'hello'", PlatformHelper.ShellEscape("hello"));
}

[Fact]
public void ShellEscape_SingleQuote_EscapedCorrectly()
{
// Bash single-quote escaping: close quote, double-quote the apostrophe, reopen quote
Assert.Equal("'it'\"'\"'s a test'", PlatformHelper.ShellEscape("it's a test"));
}

[Fact]
public void ShellEscape_SpecialChars_NotExpanded()
{
// Dollar signs, backticks, semicolons, pipes — all neutralized inside single quotes
Assert.Equal("'$HOME;rm -rf /|`whoami`'", PlatformHelper.ShellEscape("$HOME;rm -rf /|`whoami`"));
}

[Fact]
public void ShellEscape_EmptyString_ReturnsEmptyQuotes()
{
Assert.Equal("''", PlatformHelper.ShellEscape(""));
}

[Fact]
public void ShellEscape_MultipleSingleQuotes_AllEscaped()
{
Assert.Equal("''\"'\"''\"'\"''", PlatformHelper.ShellEscape("''"));
}
}
25 changes: 25 additions & 0 deletions PolyPilot.Tests/RenderThrottleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,29 @@ public void CustomThrottleInterval_Respected()
throttle.SetLastRefresh(DateTime.UtcNow.AddMilliseconds(-1100));
Assert.True(throttle.ShouldRefresh(isSessionSwitch: false, hasCompletedSessions: false));
}

[Fact]
public void CompletionRace_OnStateChangedThrottledButHandleCompleteRenders()
{
// Documents the race condition that caused stuck "Thinking" indicators:
// 1. Streaming events (AssistantTurnEndEvent) fire OnStateChanged rapidly
// 2. CompleteResponse fires OnStateChanged (throttled — dropped!)
// 3. CompleteResponse fires OnSessionComplete → HandleComplete
//
// The fix: HandleComplete calls StateHasChanged() directly instead of
// going through ScheduleRender(), guaranteeing the completion renders.
//
// This test verifies the throttle DOES drop the OnStateChanged from step 2:
var throttle = new RenderThrottle(500);

// Step 1: Streaming event refresh passes (first in window)
Assert.True(throttle.ShouldRefresh(isSessionSwitch: false, hasCompletedSessions: false));

// Step 2: CompleteResponse's OnStateChanged arrives <500ms later — THROTTLED
// completedSessions is empty at this point because HandleComplete hasn't run yet
Assert.False(throttle.ShouldRefresh(isSessionSwitch: false, hasCompletedSessions: false));

// Step 3: HandleComplete runs, adds to completedSessions, calls StateHasChanged directly
// (bypasses RefreshState/throttle entirely — this is the fix)
}
}
36 changes: 36 additions & 0 deletions PolyPilot/Components/Layout/SessionListItem.razor
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@
🔧 Fix with Copilot
</button>
<div class="menu-separator"></div>
@if (!string.IsNullOrEmpty(Session.SessionId))
{
<button class="menu-item" @onclick="() => { OnCloseMenu.InvokeAsync(); OpenInCopilotConsole(); }">
>_ Copilot Console
</button>
}
@if (!string.IsNullOrEmpty(sessionDir))
{
<button class="menu-item" @onclick="() => { OnCloseMenu.InvokeAsync(); OpenInTerminal(); }">
Expand Down Expand Up @@ -209,6 +215,36 @@
catch { }
}

private void OpenInCopilotConsole()
{
var sessionId = Session.SessionId;
if (string.IsNullOrEmpty(sessionId)) return;
var dir = GetSessionDirectory() ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
try
{
// Shell-escape all dynamic values using single quotes to prevent injection.
var safeDir = PlatformHelper.ShellEscape(dir);
var safeName = PlatformHelper.ShellEscape(Session.Name);
var safeId = PlatformHelper.ShellEscape(sessionId);

var shellScript = Path.Combine(Path.GetTempPath(), $"polypilot-copilot-{Guid.NewGuid():N}.sh");
File.WriteAllText(shellScript,
"#!/bin/bash\n" +
$"cd {safeDir}\n" +
$"echo '🔗 Resuming session:' {safeName} '('{safeId}')'\n" +
"echo '─────────────────────────────────────────'\n" +
"echo ''\n" +
$"copilot --resume {safeId} --yolo\n");
Process.Start("chmod", $"+x \"{shellScript}\"")?.WaitForExit();
Process.Start(new ProcessStartInfo("open", $"-a Terminal \"{shellScript}\"")
{
UseShellExecute = false,
CreateNoWindow = true
});
}
catch { }
}

private void OpenInVSCode()
{
var dir = GetSessionDirectory();
Expand Down
29 changes: 18 additions & 11 deletions PolyPilot/Components/Pages/Dashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -817,18 +817,25 @@

private void HandleComplete(string sessionName, string summary)
{
completedSessions.Add(sessionName);
streamingBySession.Remove(sessionName);
activityBySession.Remove(sessionName);
// Refresh sessions list immediately — RefreshState may have been throttled
// when OnStateChanged fired just before OnSessionComplete
sessions = CopilotService.GetAllSessions().ToList();
ScheduleRender();
_ = Task.Delay(10000).ContinueWith(_ =>
{
completedSessions.Remove(sessionName);
ScheduleRender();
// Marshal all state mutations + render onto the UI thread.
// HandleComplete can be called from background threads (SDK event dispatch).
// Collections like completedSessions/streamingBySession are not thread-safe.
InvokeAsync(() =>
{
if (_disposed) return;
completedSessions.Add(sessionName);
streamingBySession.Remove(sessionName);
activityBySession.Remove(sessionName);
sessions = CopilotService.GetAllSessions().ToList();
StateHasChanged();
});
_ = Task.Delay(10000).ContinueWith(_ =>
InvokeAsync(() =>
{
if (_disposed) return;
completedSessions.Remove(sessionName);
StateHasChanged();
}));
}

private void HandleContent(string sessionName, string content)
Expand Down
7 changes: 7 additions & 0 deletions PolyPilot/Models/PlatformHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@ public static class PlatformHelper
public static ConnectionMode DefaultMode => IsDesktop
? ConnectionMode.Persistent
: ConnectionMode.Remote;

/// <summary>
/// Shell-escapes a string for safe embedding in bash scripts using single quotes.
/// Single quotes prevent all shell expansion (variables, command substitution, etc.).
/// The only character that needs escaping inside single quotes is ' itself.
/// </summary>
public static string ShellEscape(string value) => "'" + value.Replace("'", "'\"'\"'") + "'";
}
Loading