diff --git a/PolyPilot.Tests/ProcessingWatchdogTests.cs b/PolyPilot.Tests/ProcessingWatchdogTests.cs index ca44235f36..6c1362c0e6 100644 --- a/PolyPilot.Tests/ProcessingWatchdogTests.cs +++ b/PolyPilot.Tests/ProcessingWatchdogTests.cs @@ -40,11 +40,22 @@ public void WatchdogCheckInterval_IsReasonable() [Fact] public void WatchdogInactivityTimeout_IsReasonable() { - // Timeout must be long enough for legitimate tool executions (>60s) + // Timeout must be long enough for legitimate pauses (>60s) // but short enough to recover from dead connections (<300s). Assert.InRange(CopilotService.WatchdogInactivityTimeoutSeconds, 60, 300); } + [Fact] + public void WatchdogToolExecutionTimeout_IsReasonable() + { + // Tool execution timeout must be long enough for long-running tools + // (e.g., UI tests, builds) but not infinite. + Assert.InRange(CopilotService.WatchdogToolExecutionTimeoutSeconds, 300, 1800); + Assert.True( + CopilotService.WatchdogToolExecutionTimeoutSeconds > CopilotService.WatchdogInactivityTimeoutSeconds, + "Tool execution timeout must be greater than base inactivity timeout"); + } + [Fact] public void WatchdogTimeout_IsGreaterThanCheckInterval() { @@ -93,10 +104,10 @@ public async Task DemoMode_MultipleSends_NoneStuck() public void SystemMessage_ConnectionLost_HasExpectedContent() { var msg = ChatMessage.SystemMessage( - "⚠️ Connection lost — no response received. You can try sending your message again."); + "⚠️ Session appears stuck — no response received. You can try sending your message again."); Assert.Equal("system", msg.Role); - Assert.Contains("Connection lost", msg.Content); + Assert.Contains("appears stuck", msg.Content); Assert.Contains("try sending", msg.Content); } @@ -188,12 +199,12 @@ public void SystemMessage_AddedToHistory_IsVisible() // Simulate what the watchdog does when clearing stuck state info.IsProcessing = true; info.History.Add(ChatMessage.SystemMessage( - "⚠️ Connection lost — no response received. You can try sending your message again.")); + "⚠️ Session appears stuck — no response received. You can try sending your message again.")); info.IsProcessing = false; Assert.Single(info.History); Assert.Equal(ChatMessageType.System, info.History[0].MessageType); - Assert.Contains("Connection lost", info.History[0].Content); + Assert.Contains("appears stuck", info.History[0].Content); Assert.False(info.IsProcessing); } @@ -357,4 +368,446 @@ await svc.ReconnectAsync(new ConnectionSettings Assert.True(stateChangedCount > 0, "OnStateChanged must fire during reconnect so UI updates"); } + + // =========================================================================== + // Regression tests for: SEND/COMPLETE race condition (generation counter) + // + // When SessionIdleEvent queues CompleteResponse via SyncContext.Post(), + // a new SendPromptAsync can sneak in before the callback executes. + // Without a generation counter, CompleteResponse would clear the NEW send's + // IsProcessing state, causing the new turn's events to become "ghost events". + // + // Evidence from diagnostic log (13:00:00 race): + // 13:00:00.238 [EVT] SessionIdleEvent ← IDLE arrives + // 13:00:00.242 [IDLE] queued ← Post() to UI thread + // 13:00:00.251 [SEND] IsProcessing=true ← NEW SEND sneaks in! + // 13:00:00.261 [COMPLETE] responseLen=0 ← Completes WRONG turn + // =========================================================================== + + [Fact] + public async Task DemoMode_RapidSends_NoGhostState() + { + // Verify that rapid sequential sends in demo mode don't leave + // IsProcessing in an inconsistent state. + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("rapid-send"); + + for (int i = 0; i < 10; i++) + { + await svc.SendPromptAsync("rapid-send", $"Message {i}"); + Assert.False(session.IsProcessing, + $"IsProcessing should be false after send {i} completes"); + } + + // All messages should have been processed + Assert.True(session.History.Count >= 10, + "All rapid sends should produce responses in demo mode"); + } + + [Fact] + public async Task DemoMode_SendAfterComplete_ProcessingStateClean() + { + // Simulates the scenario where a send follows immediately after + // a completion — the generation counter should prevent the old + // IDLE's CompleteResponse from affecting the new send. + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("send-after-complete"); + + // First send completes normally + await svc.SendPromptAsync("send-after-complete", "First message"); + Assert.False(session.IsProcessing, "First send should complete"); + + // Second send immediately after — in real code, a stale IDLE callback + // from the first turn could race with this send. + await svc.SendPromptAsync("send-after-complete", "Second message"); + Assert.False(session.IsProcessing, "Second send should also complete"); + + // Both messages should be in history + Assert.True(session.History.Count >= 2, + "Both messages should produce responses"); + } + + [Fact] + public async Task SendPromptAsync_DebugInfrastructure_WorksInDemoMode() + { + // Verify that the debug/logging infrastructure is functional. + // Note: the generation counter [SEND] log only fires in non-demo mode + // (the demo path returns before reaching that code). This test verifies + // the OnDebug event fires for other operations. + var svc = CreateService(); + + var debugMessages = new List(); + svc.OnDebug += msg => debugMessages.Add(msg); + + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + await svc.CreateSessionAsync("gen-debug"); + + // Demo init produces debug messages + Assert.NotEmpty(debugMessages); + Assert.Contains(debugMessages, m => m.Contains("Demo mode")); + } + + [Fact] + public async Task AbortSessionAsync_WorksRegardlessOfGeneration() + { + // AbortSessionAsync must always clear IsProcessing regardless of + // generation state. It bypasses the generation check (force-complete). + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("abort-gen"); + + // Manually set IsProcessing to simulate a session mid-turn + session.IsProcessing = true; + + // Abort should force-clear regardless of generation + await svc.AbortSessionAsync("abort-gen"); + + Assert.False(session.IsProcessing, + "AbortSessionAsync must always clear IsProcessing, regardless of generation"); + } + + [Fact] + public async Task AbortSessionAsync_AllowsSubsequentSend() + { + // After aborting a stuck session, user should be able to send a new message. + // This tests the full Stop → re-send flow the user described. + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("abort-resend"); + + // Send first message + await svc.SendPromptAsync("abort-resend", "First message"); + Assert.False(session.IsProcessing); + + // Simulate stuck state (what happens when CLI goes silent) + session.IsProcessing = true; + + // User clicks Stop + await svc.AbortSessionAsync("abort-resend"); + Assert.False(session.IsProcessing); + + // User sends another message — should succeed, not throw "already processing" + await svc.SendPromptAsync("abort-resend", "Message after abort"); + Assert.False(session.IsProcessing); + } + + [Fact] + public async Task StuckSession_ManuallySetProcessing_AbortClears() + { + // Simulates the exact user scenario: session stuck in "Thinking", + // user clicks Stop, gets response, can continue. + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("stuck-thinking"); + + // Start a conversation + await svc.SendPromptAsync("stuck-thinking", "Initial message"); + var historyCountBefore = session.History.Count; + + // Simulate getting stuck (events stop arriving, IsProcessing stays true) + session.IsProcessing = true; + + // In demo mode, sends return early without checking IsProcessing. + // In non-demo mode, this would throw "already processing". + // Verify the stuck state is set correctly. + Assert.True(session.IsProcessing); + + // Abort clears the stuck state + await svc.AbortSessionAsync("stuck-thinking"); + Assert.False(session.IsProcessing); + + // Now user can send again + await svc.SendPromptAsync("stuck-thinking", "Recovery message"); + Assert.False(session.IsProcessing); + Assert.True(session.History.Count > historyCountBefore, + "New messages should be added to history after abort recovery"); + } + + [Fact] + public async Task DemoMode_ConcurrentSessions_IndependentState() + { + // Generation counters are per-session. Operations on one session + // must not affect another session's state. + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var s1 = await svc.CreateSessionAsync("concurrent-1"); + var s2 = await svc.CreateSessionAsync("concurrent-2"); + var s3 = await svc.CreateSessionAsync("concurrent-3"); + + // Send to all three + await svc.SendPromptAsync("concurrent-1", "Hello 1"); + await svc.SendPromptAsync("concurrent-2", "Hello 2"); + await svc.SendPromptAsync("concurrent-3", "Hello 3"); + + // All should be in clean state + Assert.False(s1.IsProcessing, "Session 1 should not be stuck"); + Assert.False(s2.IsProcessing, "Session 2 should not be stuck"); + Assert.False(s3.IsProcessing, "Session 3 should not be stuck"); + + // Stuck one session — others unaffected + s2.IsProcessing = true; + Assert.False(s1.IsProcessing); + Assert.True(s2.IsProcessing); + Assert.False(s3.IsProcessing); + + // Send to non-stuck sessions still works + await svc.SendPromptAsync("concurrent-1", "Message while s2 stuck"); + await svc.SendPromptAsync("concurrent-3", "Message while s2 stuck"); + Assert.False(s1.IsProcessing); + Assert.False(s3.IsProcessing); + } + + [Fact] + public async Task DemoMode_AbortNotProcessing_IsNoOp() + { + // Aborting a session that isn't processing should be harmless + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("abort-noop"); + Assert.False(session.IsProcessing); + + // Should not throw or change state + await svc.AbortSessionAsync("abort-noop"); + Assert.False(session.IsProcessing); + } + + [Fact] + public async Task DemoMode_AbortNonExistentSession_IsNoOp() + { + // Aborting a session that doesn't exist should not throw + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + // Should be a no-op, not an exception + await svc.AbortSessionAsync("does-not-exist"); + } + + [Fact] + public async Task DemoMode_SendWhileProcessing_StillSucceeds() + { + // Demo mode's SendPromptAsync returns early without checking IsProcessing. + // This is by design — demo responses are simulated locally and don't conflict. + // The IsProcessing guard only applies in non-demo SDK mode. + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("double-send"); + session.IsProcessing = true; // Simulate in-flight request + + // Demo mode ignores IsProcessing — should not throw + await svc.SendPromptAsync("double-send", "Demo allows this"); + // The manually-set IsProcessing persists (demo doesn't clear it), + // but the send itself should succeed. + } + + [Fact] + public async Task DemoMode_MultipleRapidAborts_NoThrow() + { + // Multiple rapid aborts on the same session should be idempotent + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("rapid-abort"); + session.IsProcessing = true; + + // Fire multiple aborts in quick succession + await svc.AbortSessionAsync("rapid-abort"); + await svc.AbortSessionAsync("rapid-abort"); + await svc.AbortSessionAsync("rapid-abort"); + + Assert.False(session.IsProcessing); + } + + [Fact] + public async Task DemoMode_HistoryIntegrity_AfterAbortAndResend() + { + // After abort + resend, history should contain all user messages + // and should not have duplicate or missing entries. + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("history-integrity"); + + // Normal send + await svc.SendPromptAsync("history-integrity", "Message 1"); + var count1 = session.History.Count; + + // Simulate stuck and abort + session.IsProcessing = true; + await svc.AbortSessionAsync("history-integrity"); + + // Send again + await svc.SendPromptAsync("history-integrity", "Message 2"); + var count2 = session.History.Count; + + // History should have grown (user message + response for each send) + Assert.True(count2 > count1, + $"History should grow after abort+resend (was {count1}, now {count2})"); + + // All user messages should be present + var userMessages = session.History.Where(m => m.Role == "user").Select(m => m.Content).ToList(); + Assert.Contains("Message 1", userMessages); + Assert.Contains("Message 2", userMessages); + } + + [Fact] + public async Task OnStateChanged_FiresOnAbort() + { + // UI must be notified when abort clears IsProcessing + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("abort-notify"); + session.IsProcessing = true; + + var stateChangedCount = 0; + svc.OnStateChanged += () => stateChangedCount++; + + await svc.AbortSessionAsync("abort-notify"); + + Assert.True(stateChangedCount > 0, + "OnStateChanged must fire when abort clears processing state"); + } + + [Fact] + public async Task OnStateChanged_DoesNotFireOnAbortWhenNotProcessing() + { + // Abort on an already-idle session should not fire OnStateChanged + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + await svc.CreateSessionAsync("abort-idle"); + + var stateChangedCount = 0; + svc.OnStateChanged += () => stateChangedCount++; + + await svc.AbortSessionAsync("abort-idle"); + + Assert.Equal(0, stateChangedCount); + } + + // --- Bug A: Watchdog callback must not kill a new turn after abort+resend --- + + [Fact] + public async Task WatchdogCallback_AfterAbortAndResend_DoesNotKillNewTurn() + { + // Regression: if the watchdog fires and queues a callback via InvokeOnUI, + // then the user aborts + resends before the callback executes, the callback + // must detect the generation mismatch and skip — not kill the new turn. + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("watchdog-gen"); + + // Simulate first turn + await svc.SendPromptAsync("watchdog-gen", "First prompt"); + Assert.False(session.IsProcessing, "Demo mode completes immediately"); + + // Simulate second turn then abort + session.IsProcessing = true; + await svc.AbortSessionAsync("watchdog-gen"); + Assert.False(session.IsProcessing, "Abort clears processing"); + + // Simulate third turn (the new send) + await svc.SendPromptAsync("watchdog-gen", "Third prompt"); + + // After demo completes, session should be idle with response in history + Assert.False(session.IsProcessing, "New send completed successfully"); + Assert.True(session.History.Count >= 2, + "History should contain messages from successful sends"); + } + + [Fact] + public async Task AbortThenResend_PreservesNewTurnState() + { + // Verifies the abort+resend sequence leaves the session in a clean state + // where the new turn's processing is not interfered with. + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("abort-resend"); + + // Send, abort, send again — the second send must succeed cleanly + await svc.SendPromptAsync("abort-resend", "First"); + session.IsProcessing = true; // simulate stuck + await svc.AbortSessionAsync("abort-resend"); + await svc.SendPromptAsync("abort-resend", "Second"); + + Assert.False(session.IsProcessing); + var lastMsg = session.History.LastOrDefault(); + Assert.NotNull(lastMsg); + } + + // --- Bug B: Resume fallback must not race with SDK events --- + + [Fact] + public async Task ResumeFallback_DoesNotCorruptState_WhenSessionCompletesNormally() + { + // The 10s resume fallback must not clear IsProcessing if the session + // has already completed normally (HasReceivedEventsSinceResume = true). + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("resume-safe"); + + // After demo mode init, session should be idle + Assert.False(session.IsProcessing, + "Fresh session should not be stuck processing"); + } + + [Fact] + public async Task ResumeFallback_StateMutations_OnlyViaUIThread() + { + // Verify that after creating a session, state mutations from the resume + // fallback (if any) don't corrupt the history list. + // In demo mode, the fallback should never fire since events arrive immediately. + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("resume-thread-safe"); + await svc.SendPromptAsync("resume-thread-safe", "Test"); + + // Wait a moment to ensure any background tasks have run + await Task.Delay(100); + + // History should be intact — no corruption from concurrent List access + var historySnapshot = session.History.ToArray(); + Assert.True(historySnapshot.Length >= 1, "History should have at least the response"); + Assert.All(historySnapshot, msg => Assert.NotNull(msg.Content)); + } + + [Fact] + public async Task MultipleAbortResendCycles_MaintainCleanState() + { + // Stress test: rapid abort+resend cycles should not leave orphaned state + var svc = CreateService(); + await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo }); + + var session = await svc.CreateSessionAsync("stress-abort"); + + for (int i = 0; i < 5; i++) + { + await svc.SendPromptAsync("stress-abort", $"Prompt {i}"); + if (i < 4) // Don't abort the last one + { + session.IsProcessing = true; // simulate stuck + await svc.AbortSessionAsync("stress-abort"); + Assert.False(session.IsProcessing, $"Abort cycle {i} should clear processing"); + } + } + + Assert.False(session.IsProcessing, "Final state should be idle"); + // History should contain messages from all cycles + Assert.True(session.History.Count >= 5, + $"Expected at least 5 history entries from 5 send cycles, got {session.History.Count}"); + } } diff --git a/PolyPilot.Tests/ScenarioReferenceTests.cs b/PolyPilot.Tests/ScenarioReferenceTests.cs index b7f776ec9b..6fff147301 100644 --- a/PolyPilot.Tests/ScenarioReferenceTests.cs +++ b/PolyPilot.Tests/ScenarioReferenceTests.cs @@ -155,6 +155,7 @@ public void Scenario_RefreshSessionsButton_HasUnitTestCoverage() /// Scenario: "stuck-session-recovery-after-server-disconnect" /// Unit test equivalents: ProcessingWatchdogTests.WatchdogCheckInterval_IsReasonable, /// ProcessingWatchdogTests.WatchdogInactivityTimeout_IsReasonable, + /// ProcessingWatchdogTests.WatchdogToolExecutionTimeout_IsReasonable, /// ProcessingWatchdogTests.SystemMessage_ConnectionLost_HasExpectedContent, /// ProcessingWatchdogTests.SystemMessage_AddedToHistory_IsVisible /// diff --git a/PolyPilot.Tests/Scenarios/mode-switch-scenarios.json b/PolyPilot.Tests/Scenarios/mode-switch-scenarios.json index 6fbf19bf85..160d22f5dd 100644 --- a/PolyPilot.Tests/Scenarios/mode-switch-scenarios.json +++ b/PolyPilot.Tests/Scenarios/mode-switch-scenarios.json @@ -427,6 +427,7 @@ "unitTestCoverage": [ "ProcessingWatchdogTests.WatchdogCheckInterval_IsReasonable", "ProcessingWatchdogTests.WatchdogInactivityTimeout_IsReasonable", + "ProcessingWatchdogTests.WatchdogToolExecutionTimeout_IsReasonable", "ProcessingWatchdogTests.WatchdogTimeout_IsGreaterThanCheckInterval", "ProcessingWatchdogTests.SystemMessage_ConnectionLost_HasExpectedContent", "ProcessingWatchdogTests.SystemMessage_AddedToHistory_IsVisible" @@ -469,12 +470,12 @@ }, { "action": "note", - "text": "To fully test: kill the persistent server process while session is processing, then wait up to 2 minutes for the watchdog to detect inactivity and clear the stuck state. The session should show a system message: 'Connection lost — no response received.'" + "text": "To fully test: kill the persistent server process while session is processing, then wait up to 2 minutes for the watchdog to detect inactivity and clear the stuck state (10 min if a tool is running). The session should show a system message: 'Session appears stuck — no response received.'" }, { "action": "wait", "duration": 130000, - "note": "Wait for watchdog timeout (120s) + buffer. In manual testing, kill server during this wait." + "note": "Wait for watchdog timeout (120s when no tool running) + buffer. In manual testing, kill server during this wait." }, { "action": "evaluate", @@ -484,9 +485,9 @@ }, { "action": "evaluate", - "script": "Array.from(document.querySelectorAll('.chat-msg')).some(el => el.textContent.includes('Connection lost'))", + "script": "Array.from(document.querySelectorAll('.chat-msg')).some(el => el.textContent.includes('appears stuck'))", "expect": { "equals": "true" }, - "note": "System message about connection loss should appear in chat" + "note": "System message about stuck session should appear in chat" } ] }, diff --git a/PolyPilot/Components/Layout/SessionListItem.razor b/PolyPilot/Components/Layout/SessionListItem.razor index 3bbb1f4325..006c5a73b7 100644 --- a/PolyPilot/Components/Layout/SessionListItem.razor +++ b/PolyPilot/Components/Layout/SessionListItem.razor @@ -120,6 +120,13 @@ } + + + @if (!string.IsNullOrEmpty(sessionDir)) { + - + +
Debug info included
@GetBugReportDebugInfo()
@@ -110,9 +117,16 @@ else
Fix a Bug / Add Feature - +
- + +
Debug info included
@GetBugReportDebugInfo()
@@ -345,7 +359,9 @@ else OnStartRename="() => StartRename(sName)" OnCommitRename="CommitRename" OnToggleMenu="() => ToggleSessionMenu(sName)" - OnCloseMenu="() => { openMenuSession = null; }" /> + OnCloseMenu="() => { openMenuSession = null; }" + OnReportBug="() => OpenBugReportForSession(sName)" + OnFixWithCopilot="() => OpenFixItForSession(sName)" /> } } } @@ -938,6 +954,7 @@ else private bool showBugReport; private bool showFixIt; private string bugDescription = ""; + private string selectedBugSession = ""; private bool footerSubmitting; private string? footerStatus; @@ -946,6 +963,7 @@ else showBugReport = true; showFixIt = false; bugDescription = ""; + selectedBugSession = ""; footerStatus = null; StateHasChanged(); } @@ -955,10 +973,25 @@ else showFixIt = true; showBugReport = false; bugDescription = ""; + selectedBugSession = ""; footerStatus = null; StateHasChanged(); } + private void OpenBugReportForSession(string sessionName) + { + OpenBugReport(); + selectedBugSession = sessionName; + StateHasChanged(); + } + + private void OpenFixItForSession(string sessionName) + { + OpenFixIt(); + selectedBugSession = sessionName; + StateHasChanged(); + } + private void CloseFooterPanel() { showBugReport = false; @@ -983,6 +1016,20 @@ else sb.AppendLine($"FallbackNotice: {CopilotService.FallbackNotice}"); sb.AppendLine($"LastDebug: {CopilotService.LastDebugMessage}"); + if (!string.IsNullOrEmpty(selectedBugSession)) + { + var session = CopilotService.GetAllSessions().FirstOrDefault(s => s.Name == selectedBugSession); + if (session != null) + { + sb.AppendLine($"--- Selected Session: {session.Name} ---"); + sb.AppendLine($"IsProcessing: {session.IsProcessing}"); + sb.AppendLine($"Model: {session.Model}"); + sb.AppendLine($"MessageCount: {session.MessageCount}"); + sb.AppendLine($"LastUpdatedAt: {session.LastUpdatedAt:o}"); + sb.AppendLine($"WorkingDirectory: {session.WorkingDirectory}"); + } + } + try { var crashPath = Path.Combine( @@ -1018,12 +1065,13 @@ else try { var debugInfo = GetBugReportDebugInfo(); + var sessionLabel = !string.IsNullOrEmpty(selectedBugSession) ? $" [{selectedBugSession}]" : ""; var body = $"## Description\n{bugDescription.Trim()}\n\n## Debug Info\n```\n{debugInfo}\n```"; var title = bugDescription.Trim().Split('\n')[0]; if (title.Length > 80) title = title[..80]; var psi = new System.Diagnostics.ProcessStartInfo("gh", - $"issue create --repo PureWeen/PolyPilot --title \"[Bug Report] {EscapeForShell(title)}\" --body \"{EscapeForShell(body)}\" --label bug") + $"issue create --repo PureWeen/PolyPilot --title \"[Bug Report]{EscapeForShell(sessionLabel)} {EscapeForShell(title)}\" --body \"{EscapeForShell(body)}\" --label bug") { RedirectStandardOutput = true, RedirectStandardError = true, @@ -1081,6 +1129,8 @@ else { var debugInfo = GetBugReportDebugInfo(); var desc = bugDescription.Trim(); + if (!string.IsNullOrEmpty(selectedBugSession)) + desc = $"[Session: {selectedBugSession}] {desc}"; var slugTitle = new string(desc.Split('\n')[0] .Where(c => char.IsLetterOrDigit(c) || c == ' ' || c == '-') .ToArray()) diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor.css b/PolyPilot/Components/Layout/SessionSidebar.razor.css index 3a188ff503..b48fb5021a 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor.css +++ b/PolyPilot/Components/Layout/SessionSidebar.razor.css @@ -1212,6 +1212,31 @@ font-family: inherit; box-sizing: border-box; } + +.bug-report-select { + width: 100%; + padding: 0.4rem; + background: var(--bg-primary); + border: 1px solid var(--control-border); + border-radius: 6px; + color: var(--text-primary); + font-size: 0.75rem; + font-family: inherit; + box-sizing: border-box; + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a0b4cc' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.5rem center; +} +.bug-report-select option { + background: var(--bg-primary); + color: var(--text-primary); +} +.bug-report-select:focus { + outline: none; + border-color: var(--accent-primary); +} .bug-report-textarea:focus { outline: none; border-color: var(--accent-primary); diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index fe722674d4..156c08a21e 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -196,7 +196,7 @@ private void CompleteReasoningMessages(SessionState state, string sessionName) private void HandleSessionEvent(SessionState state, SessionEvent evt) { - state.HasReceivedEventsSinceResume = true; + Volatile.Write(ref state.HasReceivedEventsSinceResume, true); Interlocked.Exchange(ref state.LastEventAtTicks, DateTime.UtcNow.Ticks); var sessionName = state.Info.Name; var isCurrentState = _sessions.TryGetValue(sessionName, out var current) && ReferenceEquals(current, state); @@ -274,6 +274,7 @@ void Invoke(Action action) case ToolExecutionStartEvent toolStart: if (toolStart.Data == null) break; + Interlocked.Increment(ref state.ActiveToolCallCount); var startToolName = toolStart.Data.ToolName ?? "unknown"; var startCallId = toolStart.Data.ToolCallId ?? ""; var toolInput = ExtractToolInput(toolStart.Data); @@ -309,6 +310,7 @@ void Invoke(Action action) case ToolExecutionCompleteEvent toolDone: if (toolDone.Data == null) break; + Interlocked.Decrement(ref state.ActiveToolCallCount); var completeCallId = toolDone.Data.ToolCallId ?? ""; var completeToolName = toolDone.Data?.GetType().GetProperty("ToolName")?.GetValue(toolDone.Data)?.ToString(); var resultStr = FormatToolResult(toolDone.Data!.Result); @@ -351,6 +353,7 @@ void Invoke(Action action) case AssistantTurnStartEvent: state.HasReceivedDeltasThisTurn = false; + Interlocked.Exchange(ref state.ActiveToolCallCount, 0); Invoke(() => { OnTurnStart?.Invoke(sessionName); @@ -377,12 +380,17 @@ void Invoke(Action action) { Debug($"[EVT-ERR] '{sessionName}' CompleteReasoningMessages threw before CompleteResponse: {ex}"); } + // Capture the generation at the time the IDLE event arrives (on the SDK thread). + // CompleteResponse will verify this matches the current generation to avoid + // completing a turn that was superseded by a new SendPromptAsync call. + var idleGeneration = Interlocked.Read(ref state.ProcessingGeneration); Invoke(() => { Debug($"[IDLE] '{sessionName}' CompleteResponse dispatched " + $"(syncCtx={(_syncContext != null ? "UI" : "inline")}, " + - $"IsProcessing={state.Info.IsProcessing}, thread={Environment.CurrentManagedThreadId})"); - CompleteResponse(state); + $"IsProcessing={state.Info.IsProcessing}, gen={idleGeneration}/{Interlocked.Read(ref state.ProcessingGeneration)}, " + + $"thread={Environment.CurrentManagedThreadId})"); + CompleteResponse(state, idleGeneration); }); // Refresh git branch — agent may have switched branches state.Info.GitBranch = GetGitBranch(state.Info.WorkingDirectory); @@ -603,13 +611,33 @@ private void FlushCurrentResponse(SessionState state) state.HasReceivedDeltasThisTurn = false; } - private void CompleteResponse(SessionState state) + /// + /// Completes the current response for a session. The + /// parameter prevents a stale IDLE callback from completing a different turn than the one + /// that produced it. Pass null to skip the generation check (e.g. from error paths + /// or the watchdog where we always want to force-complete). + /// + private void CompleteResponse(SessionState state, long? expectedGeneration = null) { if (!state.Info.IsProcessing) { Debug($"[COMPLETE] '{state.Info.Name}' CompleteResponse skipped — IsProcessing already false"); return; // Already completed (e.g. timeout) } + + // Guard against the SEND/COMPLETE race: if a new SendPromptAsync incremented the + // generation between when SessionIdleEvent was received and when this callback + // executes on the UI thread, this IDLE belongs to the OLD turn — skip it. + if (expectedGeneration.HasValue) + { + var currentGen = Interlocked.Read(ref state.ProcessingGeneration); + if (expectedGeneration.Value != currentGen) + { + Debug($"[COMPLETE] '{state.Info.Name}' CompleteResponse skipped — generation mismatch " + + $"(idle={expectedGeneration.Value}, current={currentGen}). A new SEND superseded this turn."); + return; + } + } Debug($"[COMPLETE] '{state.Info.Name}' CompleteResponse executing " + $"(responseLen={state.CurrentResponse.Length}, thread={Environment.CurrentManagedThreadId})"); @@ -1030,8 +1058,11 @@ private void HandleReflectionAdvanceResult(SessionState state, string response, /// Interval between watchdog checks in seconds. internal const int WatchdogCheckIntervalSeconds = 15; - /// If no SDK events arrive for this many seconds, the session is considered stuck. + /// If no SDK events arrive for this many seconds (and no tool is running), the session is considered stuck. internal const int WatchdogInactivityTimeoutSeconds = 120; + /// If no SDK events arrive for this many seconds while a tool is actively executing, the session is considered stuck. + /// This is much longer because legitimate tool executions (e.g., running UI tests, long builds) can take many minutes. + internal const int WatchdogToolExecutionTimeoutSeconds = 600; private static void CancelProcessingWatchdog(SessionState state) { @@ -1064,20 +1095,37 @@ private async Task RunProcessingWatchdogAsync(SessionState state, string session var lastEventTicks = Interlocked.Read(ref state.LastEventAtTicks); var elapsed = (DateTime.UtcNow - new DateTime(lastEventTicks)).TotalSeconds; - if (elapsed >= WatchdogInactivityTimeoutSeconds) + var hasActiveTool = Interlocked.CompareExchange(ref state.ActiveToolCallCount, 0, 0) > 0; + var effectiveTimeout = hasActiveTool ? WatchdogToolExecutionTimeoutSeconds : WatchdogInactivityTimeoutSeconds; + + if (elapsed >= effectiveTimeout) { - Debug($"Session '{sessionName}' watchdog: no events for {elapsed:F0}s, clearing stuck processing state"); + var timeoutMinutes = effectiveTimeout / 60; + Debug($"Session '{sessionName}' watchdog: no events for {elapsed:F0}s " + + $"(timeout={effectiveTimeout}s, hasActiveTool={hasActiveTool}), clearing stuck processing state"); + // Capture generation before posting — same guard pattern as CompleteResponse. + // Prevents a stale watchdog callback from killing a new turn if the user + // aborts + resends between the Post() and the callback execution. + var watchdogGeneration = Interlocked.Read(ref state.ProcessingGeneration); // Marshal all state mutations to the UI thread to avoid // racing with CompleteResponse / HandleSessionEvent. InvokeOnUI(() => { if (!state.Info.IsProcessing) return; // Already completed + var currentGen = Interlocked.Read(ref state.ProcessingGeneration); + if (watchdogGeneration != currentGen) + { + Debug($"Session '{sessionName}' watchdog callback skipped — generation mismatch " + + $"(watchdog={watchdogGeneration}, current={currentGen}). A new SEND superseded this turn."); + return; + } CancelProcessingWatchdog(state); + Interlocked.Exchange(ref state.ActiveToolCallCount, 0); state.Info.IsProcessing = false; state.Info.History.Add(ChatMessage.SystemMessage( - "⚠️ Connection lost — no response received. You can try sending your message again.")); + "⚠️ Session appears stuck — no response received. You can try sending your message again.")); state.ResponseCompletion?.TrySetResult(""); - OnError?.Invoke(sessionName, $"Connection appears lost — no events received for over {WatchdogInactivityTimeoutSeconds / 60} minute(s)."); + OnError?.Invoke(sessionName, $"Session appears stuck — no events received for over {timeoutMinutes} minute(s)."); OnStateChanged?.Invoke(); }); break; diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index bd8a7e4d8c..a87a75a6c4 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -199,11 +199,19 @@ private class SessionState public TaskCompletionSource? ResponseCompletion { get; set; } public StringBuilder CurrentResponse { get; } = new(); public bool HasReceivedDeltasThisTurn { get; set; } - public bool HasReceivedEventsSinceResume { get; set; } + public bool HasReceivedEventsSinceResume; public string? LastMessageId { get; set; } public bool SkipReflectionEvaluationOnce { get; set; } public long LastEventAtTicks = DateTime.UtcNow.Ticks; public CancellationTokenSource? ProcessingWatchdog { get; set; } + /// Number of tool calls started but not yet completed this turn. + public int ActiveToolCallCount; + /// + /// Monotonically increasing counter incremented each time a new prompt is sent. + /// Used by CompleteResponse to avoid completing a different turn than the one + /// that produced the SessionIdleEvent (race between SEND and queued COMPLETE). + /// + public long ProcessingGeneration; } private void Debug(string message) @@ -1159,17 +1167,28 @@ public async Task ResumeSessionAsync(string sessionId, string state.ResponseCompletion = new TaskCompletionSource(); Debug($"Session '{displayName}' is still processing (was mid-turn when app restarted)"); + // Start the processing watchdog so the session doesn't get stuck + // forever if the CLI goes silent after resume (same as SendPromptAsync). + StartProcessingWatchdog(state, displayName); + _ = Task.Run(async () => { await Task.Delay(TimeSpan.FromSeconds(10)); - if (state.Info.IsProcessing && !state.HasReceivedEventsSinceResume) + // Marshal all state mutations to the UI thread to avoid racing with + // HandleSessionEvent / CompleteResponse (same pattern as the watchdog). + InvokeOnUI(() => { - Debug($"Session '{displayName}' processing timeout — no new events after resume, clearing stale state"); - state.Info.IsProcessing = false; - state.ResponseCompletion?.TrySetResult("timeout"); - state.Info.History.Add(ChatMessage.SystemMessage("⏹ Previous turn appears to have ended. Ready for new input.")); - InvokeOnUI(() => OnStateChanged?.Invoke()); - } + if (state.Info.IsProcessing && !Volatile.Read(ref state.HasReceivedEventsSinceResume)) + { + Debug($"Session '{displayName}' processing timeout — no new events after resume, clearing stale state"); + CancelProcessingWatchdog(state); + state.Info.IsProcessing = false; + Interlocked.Exchange(ref state.ActiveToolCallCount, 0); + state.ResponseCompletion?.TrySetResult("timeout"); + state.Info.History.Add(ChatMessage.SystemMessage("⏹ Previous turn appears to have ended. Ready for new input.")); + OnStateChanged?.Invoke(); + } + }); }); } @@ -1436,7 +1455,9 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis throw new InvalidOperationException("Session is already processing a request."); state.Info.IsProcessing = true; - Debug($"[SEND] '{sessionName}' IsProcessing=true (thread={Environment.CurrentManagedThreadId})"); + Interlocked.Increment(ref state.ProcessingGeneration); + Interlocked.Exchange(ref state.ActiveToolCallCount, 0); // Reset stale tool count from previous turn + Debug($"[SEND] '{sessionName}' IsProcessing=true gen={Interlocked.Read(ref state.ProcessingGeneration)} (thread={Environment.CurrentManagedThreadId})"); state.ResponseCompletion = new TaskCompletionSource(); state.CurrentResponse.Clear(); StartProcessingWatchdog(state, sessionName); @@ -1581,7 +1602,21 @@ public async Task AbortSessionAsync(string sessionName) Debug($"Abort failed for '{sessionName}': {ex.Message}"); } + // Flush any accumulated streaming content to history before clearing state. + // Without this, clicking Stop discards the partial response the user was waiting for. + var partialResponse = state.CurrentResponse.ToString(); + if (!string.IsNullOrEmpty(partialResponse)) + { + var msg = new ChatMessage("assistant", partialResponse, DateTime.Now); + state.Info.History.Add(msg); + state.Info.MessageCount = state.Info.History.Count; + if (!string.IsNullOrEmpty(state.Info.SessionId)) + _ = _chatDb.AddMessageAsync(state.Info.SessionId, msg); + } + state.CurrentResponse.Clear(); + state.Info.IsProcessing = false; + Interlocked.Exchange(ref state.ActiveToolCallCount, 0); CancelProcessingWatchdog(state); state.ResponseCompletion?.TrySetCanceled(); OnStateChanged?.Invoke();