diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3c545f4153..9a6e65d472 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -137,6 +137,40 @@ When a prompt is sent, the SDK emits events processed by `HandleSessionEvent` in 5. `AssistantIntentEvent` → intent/plan updates 6. `SessionIdleEvent` → turn complete, response finalized +### Processing Watchdog +The processing watchdog (`RunProcessingWatchdogAsync` in `CopilotService.Events.cs`) detects stuck sessions by checking how long since the last SDK event. It checks every 15 seconds and has two timeout tiers: +- **120 seconds** (inactivity timeout) — for sessions with no tool activity +- **600 seconds** (tool execution timeout) — used when ANY of these are true: + - A tool call is actively running (`ActiveToolCallCount > 0`) + - The session was resumed mid-turn after app restart (`IsResumed`) + - Tools have been used this turn (`HasUsedToolsThisTurn`) — even between tool rounds when the model is thinking + +The 10-second resume timeout was removed — the watchdog handles all stuck-session detection. + +When the watchdog fires, it marshals state mutations to the UI thread via `InvokeOnUI()` and adds a system warning message. All code paths that set `IsProcessing = false` must go through the UI thread. + +### Diagnostic Log Tags +The event diagnostics log (`~/.polypilot/event-diagnostics.log`) uses these tags: +- `[SEND]` — prompt sent, IsProcessing set to true +- `[EVT]` — SDK event received (only SessionIdleEvent, AssistantTurnEndEvent, SessionErrorEvent) +- `[IDLE]` — SessionIdleEvent dispatched to CompleteResponse +- `[COMPLETE]` — CompleteResponse executed or skipped +- `[RECONNECT]` — session replaced after disconnect +- `[ERROR]` — SessionErrorEvent or SendAsync/reconnect failure cleared IsProcessing +- `[ABORT]` — user-initiated abort cleared IsProcessing +- `[BRIDGE-COMPLETE]` — bridge OnTurnEnd cleared IsProcessing +- `[INTERRUPTED]` — app restart detected interrupted turn (watchdog timeout after resume) + +Every code path that sets `IsProcessing = false` MUST have a diagnostic log entry. This is critical for debugging stuck-session issues. + +### Thread Safety: IsProcessing Mutations +All mutations to `state.Info.IsProcessing` must be marshaled to the UI thread. SDK events arrive on background threads. Use `InvokeOnUI()` (not bare `Invoke()`) to combine state mutation + notification in a single callback. Key patterns: +- **CompleteResponse**: Already runs on UI thread (dispatched via `Invoke()`) +- **Watchdog callback**: Uses `InvokeOnUI()` with generation guard +- **SessionErrorEvent**: Uses `InvokeOnUI()` to combine OnError + IsProcessing + OnStateChanged +- **Resume fallback**: Removed (watchdog handles it) +- **SendAsync error paths**: Run on UI thread inline (in SendPromptAsync's catch blocks) + ### Model Selection The model is set at **session creation time** via `SessionConfig.Model`. The SDK does **not** support changing models per-message or mid-session — `MessageOptions` has no `Model` property. @@ -155,7 +189,7 @@ When a user changes the model via the UI dropdown: Avoid `@bind:event="oninput"` — causes round-trip lag per keystroke. Use plain HTML inputs with JS event listeners and read values via `JS.InvokeAsync("eval", "document.getElementById('id')?.value")` on submit. ### Session Persistence -- Active sessions: `~/.polypilot/active-sessions.json` +- Active sessions: `~/.polypilot/active-sessions.json` (includes `LastPrompt` — last user message if session was processing during save) - Session state: `~/.copilot/session-state//events.jsonl` (SDK-managed, stays in ~/.copilot) - UI state: `~/.polypilot/ui-state.json` - Settings: `~/.polypilot/settings.json` @@ -206,6 +240,16 @@ Test files in `PolyPilot.Tests/`: - `PlatformHelperTests.cs` — Platform detection - `ToolResultFormattingTests.cs` — Tool output formatting - `UiStatePersistenceTests.cs` — UI state save/load +- `ProcessingWatchdogTests.cs` — Watchdog constants, timeout selection, HasUsedToolsThisTurn, IsResumed +- `CliPathResolutionTests.cs` — CLI path resolution +- `InitializationModeTests.cs` — Mode initialization +- `PersistentModeTests.cs` — Persistent mode behavior +- `ReflectionCycleTests.cs` — Reflection cycle logic +- `SessionDisposalResilienceTests.cs` — Session disposal +- `RenderThrottleTests.cs` — Render throttling +- `DevTunnelServiceTests.cs` — DevTunnel service +- `WsBridgeServerAuthTests.cs` — Bridge auth +- `ModelSelectionTests.cs` — Model selection UI scenario definitions live in `PolyPilot.Tests/Scenarios/mode-switch-scenarios.json` — executable via MauiDevFlow CDP commands against a running app. diff --git a/PolyPilot.Tests/ChatMessageTests.cs b/PolyPilot.Tests/ChatMessageTests.cs index 4664a91605..b51356d7c8 100644 --- a/PolyPilot.Tests/ChatMessageTests.cs +++ b/PolyPilot.Tests/ChatMessageTests.cs @@ -140,6 +140,75 @@ public void DefaultProperties_AreCorrect() Assert.Null(msg.ReasoningId); Assert.Null(msg.ToolCallId); } + + [Fact] + public void Model_DefaultsToNull() + { + var msg = ChatMessage.AssistantMessage("test"); + Assert.Null(msg.Model); + } + + [Fact] + public void Model_CanBeSetViaInitializer() + { + var msg = new ChatMessage("assistant", "test", DateTime.Now) { Model = "gpt-4.1" }; + Assert.Equal("gpt-4.1", msg.Model); + } + + [Fact] + public void Model_PreservedOnAssistantMessages() + { + var msg = new ChatMessage("assistant", "response", DateTime.Now) { Model = "claude-sonnet-4.5" }; + Assert.True(msg.IsAssistant); + Assert.Equal("claude-sonnet-4.5", msg.Model); + } + + [Fact] + public void Model_NullForUserMessages() + { + var msg = ChatMessage.UserMessage("hello"); + Assert.Null(msg.Model); + } + + // --- Interrupted turn system messages --- + + [Fact] + public void InterruptedTurn_SystemMessage_ContainsWarning() + { + var interruptMsg = "⚠️ Your previous request was interrupted by an app restart. You may need to resend your last message."; + var msg = ChatMessage.SystemMessage(interruptMsg); + + Assert.Equal("system", msg.Role); + Assert.Equal(ChatMessageType.System, msg.MessageType); + Assert.Contains("interrupted by an app restart", msg.Content); + Assert.Contains("resend your last message", msg.Content); + Assert.True(msg.IsComplete); + } + + [Fact] + public void InterruptedTurn_SystemMessage_IncludesLastPrompt() + { + var lastPrompt = "fix the authentication bug in UserController.cs"; + var truncated = lastPrompt.Length > 80 ? lastPrompt[..80] + "…" : lastPrompt; + var interruptMsg = $"⚠️ Your previous request was interrupted by an app restart. You may need to resend your last message.\n📝 Last message: \"{truncated}\""; + var msg = ChatMessage.SystemMessage(interruptMsg); + + Assert.Contains("Last message:", msg.Content); + Assert.Contains("fix the authentication bug", msg.Content); + } + + [Fact] + public void InterruptedTurn_SystemMessage_TruncatesLongPrompt() + { + var longPrompt = new string('x', 200); + var truncated = longPrompt[..80] + "…"; + var interruptMsg = $"⚠️ Your previous request was interrupted by an app restart. You may need to resend your last message.\n📝 Last message: \"{truncated}\""; + var msg = ChatMessage.SystemMessage(interruptMsg); + + Assert.Contains("…", msg.Content); + // The truncated version should be 80 chars + ellipsis, not the full 200 + Assert.DoesNotContain(longPrompt, msg.Content); + } } public class ToolActivityTests diff --git a/PolyPilot.Tests/ProcessingWatchdogTests.cs b/PolyPilot.Tests/ProcessingWatchdogTests.cs index 6c1362c0e6..52c2d6b1a1 100644 --- a/PolyPilot.Tests/ProcessingWatchdogTests.cs +++ b/PolyPilot.Tests/ProcessingWatchdogTests.cs @@ -810,4 +810,231 @@ public async Task MultipleAbortResendCycles_MaintainCleanState() Assert.True(session.History.Count >= 5, $"Expected at least 5 history entries from 5 send cycles, got {session.History.Count}"); } + + // =========================================================================== + // Watchdog timeout selection logic + // Tests the 3-way condition: hasActiveTool || IsResumed || HasUsedToolsThisTurn + // SessionState is private, so we replicate the decision logic inline using + // local variables that mirror the watchdog algorithm in CopilotService.Events.cs. + // =========================================================================== + + [Fact] + public void HasUsedToolsThisTurn_DefaultsFalse() + { + // Mirrors SessionState.HasUsedToolsThisTurn default (bool default = false) + bool hasUsedToolsThisTurn = default; + Assert.False(hasUsedToolsThisTurn); + } + + [Fact] + public void HasUsedToolsThisTurn_CanBeSet() + { + // Mirrors setting HasUsedToolsThisTurn = true on ToolExecutionStartEvent + bool hasUsedToolsThisTurn = false; + hasUsedToolsThisTurn = true; + Assert.True(hasUsedToolsThisTurn); + } + + [Fact] + public void HasUsedToolsThisTurn_ResetByCompleteResponse() + { + // Mirrors CompleteResponse resetting HasUsedToolsThisTurn = false + bool hasUsedToolsThisTurn = true; + // CompleteResponse resets the field + hasUsedToolsThisTurn = false; + Assert.False(hasUsedToolsThisTurn); + } + + [Fact] + public void WatchdogTimeoutSelection_NoTools_UsesInactivityTimeout() + { + // When no tool activity and not resumed → use shorter inactivity timeout + int activeToolCallCount = 0; + bool isResumed = false; + bool hasUsedToolsThisTurn = false; + + var hasActiveTool = Interlocked.CompareExchange(ref activeToolCallCount, 0, 0) > 0; + var useToolTimeout = hasActiveTool || isResumed || hasUsedToolsThisTurn; + var effectiveTimeout = useToolTimeout + ? CopilotService.WatchdogToolExecutionTimeoutSeconds + : CopilotService.WatchdogInactivityTimeoutSeconds; + + Assert.Equal(CopilotService.WatchdogInactivityTimeoutSeconds, effectiveTimeout); + Assert.Equal(120, effectiveTimeout); + } + + [Fact] + public void WatchdogTimeoutSelection_ActiveTool_UsesToolTimeout() + { + // When ActiveToolCallCount > 0 → use longer tool execution timeout + int activeToolCallCount = 1; + bool isResumed = false; + bool hasUsedToolsThisTurn = false; + + var hasActiveTool = Interlocked.CompareExchange(ref activeToolCallCount, 0, 0) > 0; + var useToolTimeout = hasActiveTool || isResumed || hasUsedToolsThisTurn; + var effectiveTimeout = useToolTimeout + ? CopilotService.WatchdogToolExecutionTimeoutSeconds + : CopilotService.WatchdogInactivityTimeoutSeconds; + + Assert.Equal(CopilotService.WatchdogToolExecutionTimeoutSeconds, effectiveTimeout); + Assert.Equal(600, effectiveTimeout); + } + + [Fact] + public void WatchdogTimeoutSelection_ResumedSession_UsesToolTimeout() + { + // When session is resumed (IsResumed=true) → use longer tool timeout + // because resumed sessions may have in-flight tool calls from before restart + int activeToolCallCount = 0; + bool isResumed = true; + bool hasUsedToolsThisTurn = false; + + var hasActiveTool = Interlocked.CompareExchange(ref activeToolCallCount, 0, 0) > 0; + var useToolTimeout = hasActiveTool || isResumed || hasUsedToolsThisTurn; + var effectiveTimeout = useToolTimeout + ? CopilotService.WatchdogToolExecutionTimeoutSeconds + : CopilotService.WatchdogInactivityTimeoutSeconds; + + Assert.Equal(CopilotService.WatchdogToolExecutionTimeoutSeconds, effectiveTimeout); + Assert.Equal(600, effectiveTimeout); + } + + [Fact] + public void WatchdogTimeoutSelection_HasUsedTools_UsesToolTimeout() + { + // When tools have been used this turn (HasUsedToolsThisTurn=true) → use longer + // tool timeout even between tool rounds when the model is thinking + int activeToolCallCount = 0; + bool isResumed = false; + bool hasUsedToolsThisTurn = true; + + var hasActiveTool = Interlocked.CompareExchange(ref activeToolCallCount, 0, 0) > 0; + var useToolTimeout = hasActiveTool || isResumed || hasUsedToolsThisTurn; + var effectiveTimeout = useToolTimeout + ? CopilotService.WatchdogToolExecutionTimeoutSeconds + : CopilotService.WatchdogInactivityTimeoutSeconds; + + Assert.Equal(CopilotService.WatchdogToolExecutionTimeoutSeconds, effectiveTimeout); + Assert.Equal(600, effectiveTimeout); + } + + [Fact] + public void HasUsedToolsThisTurn_ResetOnNewSend() + { + // SendPromptAsync resets HasUsedToolsThisTurn alongside ActiveToolCallCount + // to prevent stale tool-usage from a previous turn inflating the timeout + bool hasUsedToolsThisTurn = true; + // SendPromptAsync resets it + hasUsedToolsThisTurn = false; + int activeToolCallCount = 0; + bool isResumed = false; + + var hasActiveTool = Interlocked.CompareExchange(ref activeToolCallCount, 0, 0) > 0; + var useToolTimeout = hasActiveTool || isResumed || hasUsedToolsThisTurn; + var effectiveTimeout = useToolTimeout + ? CopilotService.WatchdogToolExecutionTimeoutSeconds + : CopilotService.WatchdogInactivityTimeoutSeconds; + + Assert.Equal(120, effectiveTimeout); + } + + [Fact] + public void IsResumed_ClearedAfterFirstTurn() + { + // IsResumed is only set when session was mid-turn at restart, + // and should be cleared after the first successful CompleteResponse + var info = new AgentSessionInfo { Name = "test", Model = "test", IsResumed = true }; + Assert.True(info.IsResumed); + + // CompleteResponse clears it + info.IsResumed = false; + Assert.False(info.IsResumed); + + // Subsequent turns use inactivity timeout (120s), not tool timeout (600s) + int activeToolCallCount = 0; + bool hasUsedToolsThisTurn = false; + + var hasActiveTool = Interlocked.CompareExchange(ref activeToolCallCount, 0, 0) > 0; + var useToolTimeout = hasActiveTool || info.IsResumed || hasUsedToolsThisTurn; + var effectiveTimeout = useToolTimeout + ? CopilotService.WatchdogToolExecutionTimeoutSeconds + : CopilotService.WatchdogInactivityTimeoutSeconds; + + Assert.Equal(120, effectiveTimeout); + } + + [Fact] + public void IsResumed_OnlySetWhenStillProcessing() + { + // IsResumed should only be true when session was mid-turn at restart + // Idle-resumed sessions should NOT get the 600s timeout + var idleResumed = new AgentSessionInfo { Name = "idle", Model = "test", IsResumed = false }; + var midTurnResumed = new AgentSessionInfo { Name = "mid", Model = "test", IsResumed = true }; + + Assert.False(idleResumed.IsResumed); + Assert.True(midTurnResumed.IsResumed); + } + + [Fact] + public void IsResumed_ClearedOnAbort() + { + // Abort must clear IsResumed so subsequent turns use 120s timeout + var info = new AgentSessionInfo { Name = "t", Model = "m", IsResumed = true }; + Assert.True(info.IsResumed); + + // Simulate abort path + info.IsProcessing = false; + info.IsResumed = false; + + Assert.False(info.IsResumed); + } + + [Fact] + public void IsResumed_ClearedOnError() + { + // SessionErrorEvent must clear IsResumed + var info = new AgentSessionInfo { Name = "t", Model = "m", IsResumed = true }; + + // Simulate error path + info.IsProcessing = false; + info.IsResumed = false; + + Assert.False(info.IsResumed); + } + + [Fact] + public void IsResumed_ClearedOnWatchdogTimeout() + { + // Watchdog timeout must clear IsResumed so next turns don't get 600s + var info = new AgentSessionInfo { Name = "t", Model = "m", IsResumed = true }; + + // Simulate watchdog timeout path + info.IsProcessing = false; + info.IsResumed = false; + + // Verify next turn would use 120s + int activeToolCallCount = 0; + bool hasUsedToolsThisTurn = false; + var hasActiveTool = Interlocked.CompareExchange(ref activeToolCallCount, 0, 0) > 0; + var useToolTimeout = hasActiveTool || info.IsResumed || hasUsedToolsThisTurn; + var effectiveTimeout = useToolTimeout + ? CopilotService.WatchdogToolExecutionTimeoutSeconds + : CopilotService.WatchdogInactivityTimeoutSeconds; + + Assert.Equal(120, effectiveTimeout); + } + + [Fact] + public void HasUsedToolsThisTurn_VolatileConsistency() + { + // Verify that Volatile.Write/Read round-trips correctly + // (mirrors the cross-thread pattern: SDK thread writes, watchdog timer reads) + bool field = false; + Volatile.Write(ref field, true); + Assert.True(Volatile.Read(ref field)); + + Volatile.Write(ref field, false); + Assert.False(Volatile.Read(ref field)); + } } diff --git a/PolyPilot.Tests/SessionPersistenceTests.cs b/PolyPilot.Tests/SessionPersistenceTests.cs index c43ddf27a7..cef584c2ed 100644 --- a/PolyPilot.Tests/SessionPersistenceTests.cs +++ b/PolyPilot.Tests/SessionPersistenceTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using PolyPilot.Services; namespace PolyPilot.Tests; @@ -280,4 +281,71 @@ public void Merge_ActiveEntriesNotSubjectToDirectoryCheck() Assert.Single(result); Assert.Equal("active-no-dir", result[0].SessionId); } + + // --- ActiveSessionEntry.LastPrompt --- + + [Fact] + public void ActiveSessionEntry_LastPrompt_RoundTrips() + { + var entry = new ActiveSessionEntry + { + SessionId = "s1", + DisplayName = "Session1", + Model = "gpt-4.1", + WorkingDirectory = "/w", + LastPrompt = "fix the bug in main.cs" + }; + + var json = JsonSerializer.Serialize(entry); + var deserialized = JsonSerializer.Deserialize(json)!; + + Assert.Equal("fix the bug in main.cs", deserialized.LastPrompt); + Assert.Equal("s1", deserialized.SessionId); + Assert.Equal("Session1", deserialized.DisplayName); + } + + [Fact] + public void ActiveSessionEntry_LastPrompt_NullByDefault() + { + var entry = new ActiveSessionEntry + { + SessionId = "s2", + DisplayName = "Session2", + Model = "m", + WorkingDirectory = "/w" + }; + + Assert.Null(entry.LastPrompt); + + // Also verify null survives round-trip + var json = JsonSerializer.Serialize(entry); + var deserialized = JsonSerializer.Deserialize(json)!; + Assert.Null(deserialized.LastPrompt); + } + + [Fact] + public void MergeSessionEntries_PreservesLastPrompt() + { + // Persisted entry has a LastPrompt (session was mid-turn when app died). + // Active list is empty (app just restarted, nothing in memory yet). + // Merge should preserve the persisted entry including its LastPrompt. + var active = new List(); + var persisted = new List + { + new() + { + SessionId = "mid-turn", + DisplayName = "MidTurn", + Model = "m", + WorkingDirectory = "/w", + LastPrompt = "deploy to production" + } + }; + var closed = new HashSet(); + + var result = CopilotService.MergeSessionEntries(active, persisted, closed, _ => true); + + Assert.Single(result); + Assert.Equal("deploy to production", result[0].LastPrompt); + } } diff --git a/PolyPilot/Components/ChatMessageItem.razor b/PolyPilot/Components/ChatMessageItem.razor index e6add0f1bf..8a587f70b9 100644 --- a/PolyPilot/Components/ChatMessageItem.razor +++ b/PolyPilot/Components/ChatMessageItem.razor @@ -48,7 +48,7 @@
@((MarkupString)ChatMessageList.RenderMarkdown(Message.Content))
@if (!Compact && !IsStreaming) { -
@Message.Timestamp.ToShortTimeString()
+
@Message.Timestamp.ToShortTimeString()@(!string.IsNullOrEmpty(Message.Model) ? $" · {Message.Model}" : "")
} diff --git a/PolyPilot/Models/AgentSessionInfo.cs b/PolyPilot/Models/AgentSessionInfo.cs index cfc523e20b..66d98d4bb0 100644 --- a/PolyPilot/Models/AgentSessionInfo.cs +++ b/PolyPilot/Models/AgentSessionInfo.cs @@ -15,7 +15,7 @@ public class AgentSessionInfo // For resumed sessions public string? SessionId { get; set; } - public bool IsResumed { get; init; } + public bool IsResumed { get; set; } // Timestamp of last state change (message received, turn end, etc.) public DateTime LastUpdatedAt { get; set; } = DateTime.Now; diff --git a/PolyPilot/Models/ChatMessage.cs b/PolyPilot/Models/ChatMessage.cs index b51d61844d..794eace4c3 100644 --- a/PolyPilot/Models/ChatMessage.cs +++ b/PolyPilot/Models/ChatMessage.cs @@ -45,6 +45,9 @@ public ChatMessage(string role, string content, DateTime timestamp, ChatMessageT // Reasoning fields public string? ReasoningId { get; set; } + // Model that generated this message + public string? Model { get; set; } + // Convenience properties public bool IsUser => Role == "user"; public bool IsAssistant => Role == "assistant"; diff --git a/PolyPilot/Services/ChatDatabase.cs b/PolyPilot/Services/ChatDatabase.cs index d34b318f7d..63a26f581b 100644 --- a/PolyPilot/Services/ChatDatabase.cs +++ b/PolyPilot/Services/ChatDatabase.cs @@ -23,6 +23,8 @@ public class ChatMessageEntity public bool IsSuccess { get; set; } public string? ReasoningId { get; set; } + public string? Model { get; set; } + public DateTime Timestamp { get; set; } // Cached rendered HTML for assistant markdown messages @@ -43,7 +45,8 @@ public ChatMessage ToChatMessage() IsComplete = IsComplete, IsSuccess = IsSuccess, IsCollapsed = type is ChatMessageType.ToolCall or ChatMessageType.Reasoning, - ReasoningId = ReasoningId + ReasoningId = ReasoningId, + Model = Model }; return msg; } @@ -61,7 +64,8 @@ public static ChatMessageEntity FromChatMessage(ChatMessage msg, string sessionI IsComplete = msg.IsComplete, IsSuccess = msg.IsSuccess, ReasoningId = msg.ReasoningId, - Timestamp = msg.Timestamp + Timestamp = msg.Timestamp, + Model = msg.Model }; } } diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index cb818246de..8043611304 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -111,7 +111,7 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati _bridgeClient.OnTurnStart += (s) => { var session = GetRemoteSession(s); - if (session != null) session.IsProcessing = true; + if (session != null) { session.IsProcessing = true; } InvokeOnUI(() => OnTurnStart?.Invoke(s)); }; _bridgeClient.OnTurnEnd += (s) => @@ -120,10 +120,11 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati var session = GetRemoteSession(s); if (session != null) { + Debug($"[BRIDGE-COMPLETE] '{session.Name}' OnTurnEnd cleared IsProcessing"); session.IsProcessing = false; // Mark last assistant message as complete var lastAssistant = session.History.LastOrDefault(m => m.IsAssistant && !m.IsComplete); - if (lastAssistant != null) lastAssistant.IsComplete = true; + if (lastAssistant != null) { lastAssistant.IsComplete = true; lastAssistant.Model = session.Model; } } InvokeOnUI(() => OnTurnEnd?.Invoke(s)); }; diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 156c08a21e..e44ca5766a 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -275,6 +275,7 @@ void Invoke(Action action) case ToolExecutionStartEvent toolStart: if (toolStart.Data == null) break; Interlocked.Increment(ref state.ActiveToolCallCount); + Volatile.Write(ref state.HasUsedToolsThisTurn, true); var startToolName = toolStart.Data.ToolName ?? "unknown"; var startCallId = toolStart.Data.ToolCallId ?? ""; var toolInput = ExtractToolInput(toolStart.Data); @@ -494,10 +495,15 @@ await notifService.SendNotificationAsync( case SessionErrorEvent err: var errMsg = err.Data?.Message ?? "Unknown error"; CancelProcessingWatchdog(state); - Invoke(() => OnError?.Invoke(sessionName, errMsg)); - state.ResponseCompletion?.TrySetException(new Exception(errMsg)); - state.Info.IsProcessing = false; - Invoke(() => OnStateChanged?.Invoke()); + InvokeOnUI(() => + { + OnError?.Invoke(sessionName, errMsg); + state.ResponseCompletion?.TrySetException(new Exception(errMsg)); + Debug($"[ERROR] '{sessionName}' SessionErrorEvent cleared IsProcessing (error={errMsg})"); + state.Info.IsProcessing = false; + state.Info.IsResumed = false; + OnStateChanged?.Invoke(); + }); break; case SessionModelChangeEvent modelChange: @@ -598,9 +604,9 @@ private void TryAttachImages(MessageOptions options, List imagePaths) private void FlushCurrentResponse(SessionState state) { var text = state.CurrentResponse.ToString(); - if (string.IsNullOrEmpty(text)) return; + if (string.IsNullOrWhiteSpace(text)) return; - var msg = new ChatMessage("assistant", text, DateTime.Now); + var msg = new ChatMessage("assistant", text, DateTime.Now) { Model = state.Info.Model }; state.Info.History.Add(msg); state.Info.MessageCount = state.Info.History.Count; @@ -643,10 +649,12 @@ private void CompleteResponse(SessionState state, long? expectedGeneration = nul $"(responseLen={state.CurrentResponse.Length}, thread={Environment.CurrentManagedThreadId})"); CancelProcessingWatchdog(state); + state.HasUsedToolsThisTurn = false; + state.Info.IsResumed = false; // Clear after first successful turn var response = state.CurrentResponse.ToString(); - if (!string.IsNullOrEmpty(response)) + if (!string.IsNullOrWhiteSpace(response)) { - var msg = new ChatMessage("assistant", response, DateTime.Now); + var msg = new ChatMessage("assistant", response, DateTime.Now) { Model = state.Info.Model }; state.Info.History.Add(msg); state.Info.MessageCount = state.Info.History.Count; // If user is viewing this session, keep it read @@ -1096,13 +1104,23 @@ private async Task RunProcessingWatchdogAsync(SessionState state, string session var lastEventTicks = Interlocked.Read(ref state.LastEventAtTicks); var elapsed = (DateTime.UtcNow - new DateTime(lastEventTicks)).TotalSeconds; var hasActiveTool = Interlocked.CompareExchange(ref state.ActiveToolCallCount, 0, 0) > 0; - var effectiveTimeout = hasActiveTool ? WatchdogToolExecutionTimeoutSeconds : WatchdogInactivityTimeoutSeconds; + // Use the longer tool-execution timeout if: + // 1. A tool call is actively running (hasActiveTool), OR + // 2. This is a resumed session that was mid-turn (agent sessions routinely + // have 2-3 min gaps between events while the model reasons), OR + // 3. Tools have been executed this turn (HasUsedToolsThisTurn) — even between + // tool rounds when ActiveToolCallCount is 0, the model may spend minutes + // thinking about what tool to call next. + var useToolTimeout = hasActiveTool || state.Info.IsResumed || Volatile.Read(ref state.HasUsedToolsThisTurn); + var effectiveTimeout = useToolTimeout + ? WatchdogToolExecutionTimeoutSeconds + : WatchdogInactivityTimeoutSeconds; if (elapsed >= effectiveTimeout) { var timeoutMinutes = effectiveTimeout / 60; Debug($"Session '{sessionName}' watchdog: no events for {elapsed:F0}s " + - $"(timeout={effectiveTimeout}s, hasActiveTool={hasActiveTool}), clearing stuck processing state"); + $"(timeout={effectiveTimeout}s, hasActiveTool={hasActiveTool}, isResumed={state.Info.IsResumed}, hasUsedTools={state.HasUsedToolsThisTurn}), 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. @@ -1121,6 +1139,8 @@ private async Task RunProcessingWatchdogAsync(SessionState state, string session } CancelProcessingWatchdog(state); Interlocked.Exchange(ref state.ActiveToolCallCount, 0); + state.HasUsedToolsThisTurn = false; + state.Info.IsResumed = false; state.Info.IsProcessing = false; state.Info.History.Add(ChatMessage.SystemMessage( "⚠️ Session appears stuck — no response received. You can try sending your message again.")); diff --git a/PolyPilot/Services/CopilotService.Persistence.cs b/PolyPilot/Services/CopilotService.Persistence.cs index 41e891a77b..ac8b9e7339 100644 --- a/PolyPilot/Services/CopilotService.Persistence.cs +++ b/PolyPilot/Services/CopilotService.Persistence.cs @@ -22,7 +22,10 @@ private void SaveActiveSessionsToDisk() SessionId = s.Info.SessionId!, DisplayName = s.Info.Name, Model = s.Info.Model, - WorkingDirectory = s.Info.WorkingDirectory + WorkingDirectory = s.Info.WorkingDirectory, + LastPrompt = s.Info.IsProcessing + ? s.Info.History.LastOrDefault(m => m.IsUser)?.Content + : null }) .ToList(); @@ -120,7 +123,7 @@ public async Task RestorePreviousSessionsAsync(CancellationToken cancellationTok continue; } - await ResumeSessionAsync(entry.SessionId, entry.DisplayName, entry.WorkingDirectory, entry.Model, cancellationToken); + await ResumeSessionAsync(entry.SessionId, entry.DisplayName, entry.WorkingDirectory, entry.Model, cancellationToken, entry.LastPrompt); Debug($"Restored session: {entry.DisplayName}"); } catch (Exception ex) diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index a87a75a6c4..7d98d98e35 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -206,6 +206,11 @@ private class SessionState public CancellationTokenSource? ProcessingWatchdog { get; set; } /// Number of tool calls started but not yet completed this turn. public int ActiveToolCallCount; + /// True if any tool call has started during the current processing cycle. + /// Unlike ActiveToolCallCount which resets on AssistantTurnStartEvent, this stays + /// true until the response completes — so the watchdog uses the longer tool timeout + /// even between tool rounds when the model is thinking. + public bool HasUsedToolsThisTurn; /// /// Monotonically increasing counter incremented each time a new prompt is sent. /// Used by CompleteResponse to avoid completing a different turn than the one @@ -1034,15 +1039,15 @@ private static McpLocalServerConfig ParseMcpServerConfig(JsonElement element) { var config = new McpLocalServerConfig(); if (element.TryGetProperty("command", out var cmd)) - config.Command = cmd.GetString(); + config.Command = cmd.GetString() ?? ""; if (element.TryGetProperty("args", out var args) && args.ValueKind == JsonValueKind.Array) config.Args = args.EnumerateArray().Select(a => a.GetString() ?? "").ToList(); if (element.TryGetProperty("env", out var env) && env.ValueKind == JsonValueKind.Object) config.Env = env.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString() ?? ""); if (element.TryGetProperty("cwd", out var cwd)) - config.Cwd = cwd.GetString(); + config.Cwd = cwd.GetString() ?? ""; if (element.TryGetProperty("type", out var type)) - config.Type = type.GetString(); + config.Type = type.GetString() ?? ""; if (element.TryGetProperty("tools", out var tools) && tools.ValueKind == JsonValueKind.Array) config.Tools = tools.EnumerateArray().Select(t => t.GetString() ?? "").ToList(); if (element.TryGetProperty("timeout", out var timeout) && timeout.TryGetInt32(out var tv)) @@ -1053,7 +1058,7 @@ private static McpLocalServerConfig ParseMcpServerConfig(JsonElement element) /// /// Resume an existing session by its GUID /// - public async Task ResumeSessionAsync(string sessionId, string displayName, string? workingDirectory = null, string? model = null, CancellationToken cancellationToken = default) + public async Task ResumeSessionAsync(string sessionId, string displayName, string? workingDirectory = null, string? model = null, CancellationToken cancellationToken = default, string? lastPrompt = null) { // In remote mode, delegate to WsBridgeClient if (IsRemoteMode) @@ -1108,13 +1113,15 @@ public async Task ResumeSessionAsync(string sessionId, string var resumeConfig = new ResumeSessionConfig { Model = resumeModel, WorkingDirectory = resumeWorkingDirectory }; var copilotSession = await _client.ResumeSessionAsync(sessionId, resumeConfig, cancellationToken); + var isStillProcessing = IsSessionStillProcessing(sessionId); + var info = new AgentSessionInfo { Name = displayName, Model = resumeModel, CreatedAt = DateTime.Now, SessionId = sessionId, - IsResumed = true, + IsResumed = isStillProcessing, WorkingDirectory = resumeWorkingDirectory }; info.GitBranch = GetGitBranch(info.WorkingDirectory); @@ -1140,7 +1147,6 @@ public async Task ResumeSessionAsync(string sessionId, string // Add reconnection indicator with status context var reconnectMsg = $"🔄 Session reconnected at {DateTime.Now.ToShortTimeString()}"; - var isStillProcessing = IsSessionStillProcessing(sessionId); if (isStillProcessing) { var (lastTool, lastContent) = GetLastSessionActivity(sessionId); @@ -1148,6 +1154,11 @@ public async Task ResumeSessionAsync(string sessionId, string reconnectMsg += $" — running {lastTool}"; if (!string.IsNullOrEmpty(lastContent)) reconnectMsg += $"\n💬 Last: {(lastContent.Length > 100 ? lastContent[..100] + "…" : lastContent)}"; + if (!string.IsNullOrEmpty(lastPrompt)) + { + var truncated = lastPrompt.Length > 80 ? lastPrompt[..80] + "…" : lastPrompt; + reconnectMsg += $"\n📝 Last message: \"{truncated}\""; + } } info.History.Add(ChatMessage.SystemMessage(reconnectMsg)); @@ -1160,8 +1171,13 @@ public async Task ResumeSessionAsync(string sessionId, string Info = info }; - // If still processing, set up ResponseCompletion so events flow properly - // but add a timeout — if no new events arrive, the old turn is gone + // Wire up event handler BEFORE starting watchdog/timeout so events + // arriving immediately after SDK resume are not missed. + copilotSession.On(evt => HandleSessionEvent(state, evt)); + + // If still processing, set up ResponseCompletion so events flow properly. + // The processing watchdog (120s inactivity / 600s tool timeout) handles + // stuck sessions — no separate short timeout needed. if (isStillProcessing) { state.ResponseCompletion = new TaskCompletionSource(); @@ -1171,28 +1187,8 @@ public async Task ResumeSessionAsync(string sessionId, string // forever if the CLI goes silent after resume (same as SendPromptAsync). StartProcessingWatchdog(state, displayName); - _ = Task.Run(async () => - { - await Task.Delay(TimeSpan.FromSeconds(10)); - // Marshal all state mutations to the UI thread to avoid racing with - // HandleSessionEvent / CompleteResponse (same pattern as the watchdog). - InvokeOnUI(() => - { - 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(); - } - }); - }); - } - copilotSession.On(evt => HandleSessionEvent(state, evt)); + } if (!_sessions.TryAdd(displayName, state)) { @@ -1457,6 +1453,7 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis state.Info.IsProcessing = true; Interlocked.Increment(ref state.ProcessingGeneration); Interlocked.Exchange(ref state.ActiveToolCallCount, 0); // Reset stale tool count from previous turn + state.HasUsedToolsThisTurn = false; // Reset stale tool flag from previous turn Debug($"[SEND] '{sessionName}' IsProcessing=true gen={Interlocked.Read(ref state.ProcessingGeneration)} (thread={Environment.CurrentManagedThreadId})"); state.ResponseCompletion = new TaskCompletionSource(); state.CurrentResponse.Clear(); @@ -1550,6 +1547,7 @@ await state.Session.SendAsync(new MessageOptions Console.WriteLine($"[DEBUG] Reconnect+retry failed: {retryEx.Message}"); OnError?.Invoke(sessionName, $"Session disconnected and reconnect failed: {retryEx.Message}"); CancelProcessingWatchdog(state); + Debug($"[ERROR] '{sessionName}' reconnect+retry failed, clearing IsProcessing"); state.Info.IsProcessing = false; OnStateChanged?.Invoke(); throw; @@ -1559,6 +1557,7 @@ await state.Session.SendAsync(new MessageOptions { OnError?.Invoke(sessionName, $"SendAsync failed: {ex.Message}"); CancelProcessingWatchdog(state); + Debug($"[ERROR] '{sessionName}' SendAsync failed, clearing IsProcessing (error={ex.Message})"); state.Info.IsProcessing = false; OnStateChanged?.Invoke(); throw; @@ -1607,7 +1606,7 @@ public async Task AbortSessionAsync(string sessionName) var partialResponse = state.CurrentResponse.ToString(); if (!string.IsNullOrEmpty(partialResponse)) { - var msg = new ChatMessage("assistant", partialResponse, DateTime.Now); + var msg = new ChatMessage("assistant", partialResponse, DateTime.Now) { Model = state.Info.Model }; state.Info.History.Add(msg); state.Info.MessageCount = state.Info.History.Count; if (!string.IsNullOrEmpty(state.Info.SessionId)) @@ -1615,8 +1614,11 @@ public async Task AbortSessionAsync(string sessionName) } state.CurrentResponse.Clear(); + Debug($"[ABORT] '{sessionName}' user abort, clearing IsProcessing"); state.Info.IsProcessing = false; + state.Info.IsResumed = false; Interlocked.Exchange(ref state.ActiveToolCallCount, 0); + state.HasUsedToolsThisTurn = false; CancelProcessingWatchdog(state); state.ResponseCompletion?.TrySetCanceled(); OnStateChanged?.Invoke(); @@ -1929,6 +1931,7 @@ public class ActiveSessionEntry public string DisplayName { get; set; } = ""; public string Model { get; set; } = ""; public string? WorkingDirectory { get; set; } + public string? LastPrompt { get; set; } } public class PersistedSessionInfo