From 7a8d847ce8efbb39f383b0427a890926f3bc74d7 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 13:08:06 -0600 Subject: [PATCH 01/15] Show elapsed time on Thinking indicator during processing Add ProcessingStartedAt timestamp to AgentSessionInfo. Display elapsed time (e.g. '(45s)', '(2m 30s)') next to the Thinking indicator so users can see the session is alive during long tool executions. Only shows after 5 seconds to avoid flicker on quick responses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/ChatMessageList.razor | 13 ++++++++++++- PolyPilot/Components/ExpandedSessionView.razor | 1 + PolyPilot/Models/AgentSessionInfo.cs | 1 + PolyPilot/Services/CopilotService.Bridge.cs | 2 +- PolyPilot/Services/CopilotService.cs | 1 + PolyPilot/Services/DemoService.cs | 1 + 6 files changed, 17 insertions(+), 2 deletions(-) diff --git a/PolyPilot/Components/ChatMessageList.razor b/PolyPilot/Components/ChatMessageList.razor index d3074fa22e..3863b9b11c 100644 --- a/PolyPilot/Components/ChatMessageList.razor +++ b/PolyPilot/Components/ChatMessageList.razor @@ -84,7 +84,7 @@ {
- @(string.IsNullOrEmpty(ActivityText) ? "Thinking…" : ActivityText) + @(string.IsNullOrEmpty(ActivityText) ? "Thinking…" : ActivityText) @GetElapsedLabel()
} else @@ -92,6 +92,7 @@
Thinking + @GetElapsedLabel()
} } @@ -106,6 +107,7 @@ [Parameter] public List ToolActivities { get; set; } = new(); [Parameter] public string ActivityText { get; set; } = ""; [Parameter] public bool IsProcessing { get; set; } + [Parameter] public DateTime? ProcessingStartedAt { get; set; } [Parameter] public bool Compact { get; set; } [Parameter] public string? UserAvatarUrl { get; set; } [Parameter] public ChatLayout Layout { get; set; } = ChatLayout.Default; @@ -130,6 +132,15 @@ return ToolActivities.Where(a => !historyCallIds.Contains(a.CallId)); } + private string GetElapsedLabel() + { + if (ProcessingStartedAt == null) return ""; + var elapsed = DateTime.Now - ProcessingStartedAt.Value; + if (elapsed.TotalSeconds < 5) return ""; + if (elapsed.TotalMinutes < 1) return $"({elapsed.Seconds}s)"; + return $"({(int)elapsed.TotalMinutes}m {elapsed.Seconds}s)"; + } + private static readonly MarkdownPipeline MdPipeline = new MarkdownPipelineBuilder() .UseAdvancedExtensions().Build(); diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index 8bc8a3c6b7..54723364e2 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -117,6 +117,7 @@ ToolActivities="@ToolActivities" ActivityText="@ActivityText" IsProcessing="Session.IsProcessing" + ProcessingStartedAt="Session.ProcessingStartedAt" Compact="false" UserAvatarUrl="@UserAvatarUrl" Layout="@Layout" diff --git a/PolyPilot/Models/AgentSessionInfo.cs b/PolyPilot/Models/AgentSessionInfo.cs index cfc523e20b..67fb3be136 100644 --- a/PolyPilot/Models/AgentSessionInfo.cs +++ b/PolyPilot/Models/AgentSessionInfo.cs @@ -7,6 +7,7 @@ public class AgentSessionInfo public DateTime CreatedAt { get; init; } public int MessageCount { get; set; } public bool IsProcessing { get; set; } + public DateTime? ProcessingStartedAt { get; set; } public List History { get; } = new(); public List MessageQueue { get; } = new(); diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index cb818246de..948e67900a 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; session.ProcessingStartedAt = DateTime.Now; } InvokeOnUI(() => OnTurnStart?.Invoke(s)); }; _bridgeClient.OnTurnEnd += (s) => diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index a87a75a6c4..5682c925c6 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1455,6 +1455,7 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis throw new InvalidOperationException("Session is already processing a request."); state.Info.IsProcessing = true; + state.Info.ProcessingStartedAt = DateTime.Now; 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})"); diff --git a/PolyPilot/Services/DemoService.cs b/PolyPilot/Services/DemoService.cs index 2e4fa81d33..32adbee195 100644 --- a/PolyPilot/Services/DemoService.cs +++ b/PolyPilot/Services/DemoService.cs @@ -53,6 +53,7 @@ public async Task SimulateResponseAsync(string sessionName, string userPrompt, S if (!_sessions.TryGetValue(sessionName, out var session)) return; session.IsProcessing = true; + session.ProcessingStartedAt = DateTime.Now; Post(syncContext, () => OnStateChanged?.Invoke()); Post(syncContext, () => OnTurnStart?.Invoke(sessionName)); From d527cb9568daa06a5c59d0aa36d1f843cafc3ede Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 14:33:06 -0600 Subject: [PATCH 02/15] Add live JS timer to Thinking indicator Replace Blazor-render-dependent elapsed label with a global JS setInterval that ticks every second. Uses data-start attribute on .live-timer spans so the timer keeps counting even when the CLI goes silent and no Blazor re-renders occur. One global timer handles all sessions with zero re-render cost. Also wire ProcessingStartedAt to SessionCard (compact grid view) which was missing the parameter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/ChatMessageList.razor | 4 ++-- PolyPilot/Components/SessionCard.razor | 1 + PolyPilot/wwwroot/index.html | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/PolyPilot/Components/ChatMessageList.razor b/PolyPilot/Components/ChatMessageList.razor index 3863b9b11c..a98ab1a5a8 100644 --- a/PolyPilot/Components/ChatMessageList.razor +++ b/PolyPilot/Components/ChatMessageList.razor @@ -84,7 +84,7 @@ {
- @(string.IsNullOrEmpty(ActivityText) ? "Thinking…" : ActivityText) @GetElapsedLabel() + @(string.IsNullOrEmpty(ActivityText) ? "Thinking…" : ActivityText) @GetElapsedLabel()
} else @@ -92,7 +92,7 @@
Thinking - @GetElapsedLabel() + @GetElapsedLabel()
} } diff --git a/PolyPilot/Components/SessionCard.razor b/PolyPilot/Components/SessionCard.razor index 3785870217..4c28f49890 100644 --- a/PolyPilot/Components/SessionCard.razor +++ b/PolyPilot/Components/SessionCard.razor @@ -93,6 +93,7 @@ ToolActivities="@ToolActivities" ActivityText="@ActivityText" IsProcessing="Session.IsProcessing" + ProcessingStartedAt="Session.ProcessingStartedAt" Compact="true" UserAvatarUrl="@UserAvatarUrl" Layout="@Layout" diff --git a/PolyPilot/wwwroot/index.html b/PolyPilot/wwwroot/index.html index 9f8af4c958..dd2b431200 100644 --- a/PolyPilot/wwwroot/index.html +++ b/PolyPilot/wwwroot/index.html @@ -789,6 +789,25 @@ })(); + + From 90a3071bc4a57911d52e778c74c57df16307e7f9 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 14:39:51 -0600 Subject: [PATCH 03/15] Show model name on assistant messages, remove elapsed timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Model property to ChatMessage, set from state.Info.Model in CompleteResponse and abort flush. Display as '10:30 AM · gpt-4.1' next to the timestamp on each assistant message. Remove the elapsed timer (JS setInterval, data-start attributes, GetElapsedLabel) as it provided no actionable information. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/ChatMessageItem.razor | 2 +- PolyPilot/Components/ChatMessageList.razor | 13 +------------ .../Components/ExpandedSessionView.razor | 1 - PolyPilot/Components/SessionCard.razor | 1 - PolyPilot/Models/ChatMessage.cs | 3 +++ PolyPilot/Services/CopilotService.Events.cs | 2 +- PolyPilot/Services/CopilotService.cs | 2 +- PolyPilot/wwwroot/index.html | 19 ------------------- 8 files changed, 7 insertions(+), 36 deletions(-) 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/Components/ChatMessageList.razor b/PolyPilot/Components/ChatMessageList.razor index a98ab1a5a8..d3074fa22e 100644 --- a/PolyPilot/Components/ChatMessageList.razor +++ b/PolyPilot/Components/ChatMessageList.razor @@ -84,7 +84,7 @@ {
- @(string.IsNullOrEmpty(ActivityText) ? "Thinking…" : ActivityText) @GetElapsedLabel() + @(string.IsNullOrEmpty(ActivityText) ? "Thinking…" : ActivityText)
} else @@ -92,7 +92,6 @@
Thinking - @GetElapsedLabel()
} } @@ -107,7 +106,6 @@ [Parameter] public List ToolActivities { get; set; } = new(); [Parameter] public string ActivityText { get; set; } = ""; [Parameter] public bool IsProcessing { get; set; } - [Parameter] public DateTime? ProcessingStartedAt { get; set; } [Parameter] public bool Compact { get; set; } [Parameter] public string? UserAvatarUrl { get; set; } [Parameter] public ChatLayout Layout { get; set; } = ChatLayout.Default; @@ -132,15 +130,6 @@ return ToolActivities.Where(a => !historyCallIds.Contains(a.CallId)); } - private string GetElapsedLabel() - { - if (ProcessingStartedAt == null) return ""; - var elapsed = DateTime.Now - ProcessingStartedAt.Value; - if (elapsed.TotalSeconds < 5) return ""; - if (elapsed.TotalMinutes < 1) return $"({elapsed.Seconds}s)"; - return $"({(int)elapsed.TotalMinutes}m {elapsed.Seconds}s)"; - } - private static readonly MarkdownPipeline MdPipeline = new MarkdownPipelineBuilder() .UseAdvancedExtensions().Build(); diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index 54723364e2..8bc8a3c6b7 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -117,7 +117,6 @@ ToolActivities="@ToolActivities" ActivityText="@ActivityText" IsProcessing="Session.IsProcessing" - ProcessingStartedAt="Session.ProcessingStartedAt" Compact="false" UserAvatarUrl="@UserAvatarUrl" Layout="@Layout" diff --git a/PolyPilot/Components/SessionCard.razor b/PolyPilot/Components/SessionCard.razor index 4c28f49890..3785870217 100644 --- a/PolyPilot/Components/SessionCard.razor +++ b/PolyPilot/Components/SessionCard.razor @@ -93,7 +93,6 @@ ToolActivities="@ToolActivities" ActivityText="@ActivityText" IsProcessing="Session.IsProcessing" - ProcessingStartedAt="Session.ProcessingStartedAt" Compact="true" UserAvatarUrl="@UserAvatarUrl" Layout="@Layout" 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/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 156c08a21e..02e35b9a49 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -646,7 +646,7 @@ private void CompleteResponse(SessionState state, long? expectedGeneration = nul var response = state.CurrentResponse.ToString(); if (!string.IsNullOrEmpty(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 diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 5682c925c6..568df3c509 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1608,7 +1608,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)) diff --git a/PolyPilot/wwwroot/index.html b/PolyPilot/wwwroot/index.html index dd2b431200..9f8af4c958 100644 --- a/PolyPilot/wwwroot/index.html +++ b/PolyPilot/wwwroot/index.html @@ -789,25 +789,6 @@ })(); - - From dc5de59ecaf33b695188e7a33c7c06b78ab37699 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 14:57:04 -0600 Subject: [PATCH 04/15] Fix model label gaps: FlushCurrentResponse, bridge, DB persistence - Set Model on FlushCurrentResponse assistant messages (mid-turn flush before tool calls was missing it) - Set Model on bridge OnTurnEnd for remote mode messages - Add Model column to ChatMessageEntity and map in both directions so model name survives DB round-trips across app restarts - Remove dead ProcessingStartedAt property (unused after timer removal) - Add 4 tests for ChatMessage.Model property Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ChatMessageTests.cs | 29 +++++++++++++++++++++ PolyPilot/Models/AgentSessionInfo.cs | 1 - PolyPilot/Services/ChatDatabase.cs | 8 ++++-- PolyPilot/Services/CopilotService.Bridge.cs | 4 +-- PolyPilot/Services/CopilotService.Events.cs | 2 +- PolyPilot/Services/CopilotService.cs | 1 - PolyPilot/Services/DemoService.cs | 1 - 7 files changed, 38 insertions(+), 8 deletions(-) diff --git a/PolyPilot.Tests/ChatMessageTests.cs b/PolyPilot.Tests/ChatMessageTests.cs index 4664a91605..3118067284 100644 --- a/PolyPilot.Tests/ChatMessageTests.cs +++ b/PolyPilot.Tests/ChatMessageTests.cs @@ -140,6 +140,35 @@ 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); + } } public class ToolActivityTests diff --git a/PolyPilot/Models/AgentSessionInfo.cs b/PolyPilot/Models/AgentSessionInfo.cs index 67fb3be136..cfc523e20b 100644 --- a/PolyPilot/Models/AgentSessionInfo.cs +++ b/PolyPilot/Models/AgentSessionInfo.cs @@ -7,7 +7,6 @@ public class AgentSessionInfo public DateTime CreatedAt { get; init; } public int MessageCount { get; set; } public bool IsProcessing { get; set; } - public DateTime? ProcessingStartedAt { get; set; } public List History { get; } = new(); public List MessageQueue { get; } = new(); 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 948e67900a..7b6454d682 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; session.ProcessingStartedAt = DateTime.Now; } + if (session != null) { session.IsProcessing = true; } InvokeOnUI(() => OnTurnStart?.Invoke(s)); }; _bridgeClient.OnTurnEnd += (s) => @@ -123,7 +123,7 @@ private async Task InitializeRemoteAsync(ConnectionSettings settings, Cancellati 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 02e35b9a49..ff61fd4f45 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -600,7 +600,7 @@ private void FlushCurrentResponse(SessionState state) var text = state.CurrentResponse.ToString(); if (string.IsNullOrEmpty(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; diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 568df3c509..dbccd2fd23 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1455,7 +1455,6 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis throw new InvalidOperationException("Session is already processing a request."); state.Info.IsProcessing = true; - state.Info.ProcessingStartedAt = DateTime.Now; 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})"); diff --git a/PolyPilot/Services/DemoService.cs b/PolyPilot/Services/DemoService.cs index 32adbee195..2e4fa81d33 100644 --- a/PolyPilot/Services/DemoService.cs +++ b/PolyPilot/Services/DemoService.cs @@ -53,7 +53,6 @@ public async Task SimulateResponseAsync(string sessionName, string userPrompt, S if (!_sessions.TryGetValue(sessionName, out var session)) return; session.IsProcessing = true; - session.ProcessingStartedAt = DateTime.Now; Post(syncContext, () => OnStateChanged?.Invoke()); Post(syncContext, () => OnTurnStart?.Invoke(sessionName)); From 6f667917a265c62f0dff43020e773d4a7ac6b52f Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 15:03:51 -0600 Subject: [PATCH 05/15] Fix nullable reference warnings in ParseMcpServerConfig Add null-coalescing fallbacks for JsonElement.GetString() calls on Command, Cwd, and Type properties to silence CS8601 warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/CopilotService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index dbccd2fd23..016f0957ba 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1034,15 +1034,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)) From c59612cdee10c0a0db5b2eb3bfb00fd2a46f351f Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 15:22:05 -0600 Subject: [PATCH 06/15] Fix thread safety in SessionErrorEvent handler Marshal IsProcessing mutation and ResponseCompletion.TrySetException to the UI thread via InvokeOnUI(), matching the pattern used by the processing watchdog. Combines the error notification, state mutation, and state-changed callback into a single UI-thread dispatch to prevent race conditions with Blazor rendering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/CopilotService.Events.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index ff61fd4f45..2b6dafda38 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -494,10 +494,14 @@ 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; + OnStateChanged?.Invoke(); + }); break; case SessionModelChangeEvent modelChange: From 8f4488440b8297b18ae49d3f8f2dddd2be905116 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 15:23:20 -0600 Subject: [PATCH 07/15] feat: improve interrupted turn recovery UX with actionable messages - Change system message to actionable warning with resend hint - Add LastPrompt field to ActiveSessionEntry for persistence - Save last user message when session is processing during app exit - Show truncated last prompt in recovery message so user knows what was lost - Fire OnError to show error banner on interrupted turn recovery - Add [INTERRUPTED] debug tag for diagnostic traceability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/CopilotService.Bridge.cs | 1 + .../Services/CopilotService.Persistence.cs | 7 +++++-- PolyPilot/Services/CopilotService.cs | 18 +++++++++++++++--- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 7b6454d682..8043611304 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -120,6 +120,7 @@ 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); 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 016f0957ba..f2c8ae9a05 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1053,7 +1053,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) @@ -1180,12 +1180,20 @@ public async Task ResumeSessionAsync(string sessionId, string { if (state.Info.IsProcessing && !Volatile.Read(ref state.HasReceivedEventsSinceResume)) { - Debug($"Session '{displayName}' processing timeout — no new events after resume, clearing stale state"); + Debug($"[INTERRUPTED] 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.")); + + var interruptMsg = "⚠️ Your previous request was interrupted by an app restart. You may need to resend your last message."; + if (!string.IsNullOrEmpty(lastPrompt)) + { + var truncated = lastPrompt.Length > 80 ? lastPrompt[..80] + "…" : lastPrompt; + interruptMsg += $"\n📝 Last message: \"{truncated}\""; + } + state.Info.History.Add(ChatMessage.SystemMessage(interruptMsg)); + OnError?.Invoke(displayName, "Session was interrupted by app restart — previous turn lost"); OnStateChanged?.Invoke(); } }); @@ -1550,6 +1558,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 +1568,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; @@ -1615,6 +1625,7 @@ public async Task AbortSessionAsync(string sessionName) } state.CurrentResponse.Clear(); + Debug($"[ABORT] '{sessionName}' user abort, clearing IsProcessing"); state.Info.IsProcessing = false; Interlocked.Exchange(ref state.ActiveToolCallCount, 0); CancelProcessingWatchdog(state); @@ -1929,6 +1940,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 From d5f375d614c30d96d6bbfbbdee56ebb94bfb05ac Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 15:29:07 -0600 Subject: [PATCH 08/15] Add tests for interrupted turn recovery and LastPrompt persistence 6 new tests (670 total): - ActiveSessionEntry_LastPrompt_RoundTrips - ActiveSessionEntry_LastPrompt_NullByDefault - MergeSessionEntries_PreservesLastPrompt - InterruptedTurn_SystemMessage_ContainsWarning - InterruptedTurn_SystemMessage_IncludesLastPrompt - InterruptedTurn_SystemMessage_TruncatesLongPrompt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ChatMessageTests.cs | 40 +++++++++++++ PolyPilot.Tests/SessionPersistenceTests.cs | 68 ++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/PolyPilot.Tests/ChatMessageTests.cs b/PolyPilot.Tests/ChatMessageTests.cs index 3118067284..b51356d7c8 100644 --- a/PolyPilot.Tests/ChatMessageTests.cs +++ b/PolyPilot.Tests/ChatMessageTests.cs @@ -169,6 +169,46 @@ 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/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); + } } From c149e17cc9c18f6525f62f345061d3fa88ca549f Mon Sep 17 00:00:00 2001 From: Shane Date: Thu, 19 Feb 2026 14:18:21 -0600 Subject: [PATCH 09/15] Fix session resume killing active turns after 10 seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 10-second hardcoded timeout in ResumeSessionAsync was prematurely clearing IsProcessing on sessions that were still actively working. Tool calls (dotnet build, git push, etc.) can easily go 30-60 seconds between events, causing the resume logic to declare the turn dead. Changes: - Remove the 10-second resume timeout entirely — the processing watchdog (120s inactivity / 600s tool execution) already handles stuck sessions properly - Move event handler subscription (copilotSession.On) BEFORE the watchdog setup to fix a race where events arriving immediately after SDK resume were missed because the handler wasn't wired yet Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/CopilotService.cs | 39 ++++++---------------------- 1 file changed, 8 insertions(+), 31 deletions(-) diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index f2c8ae9a05..e15bbb30a6 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1160,8 +1160,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,36 +1176,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($"[INTERRUPTED] 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"); - - var interruptMsg = "⚠️ Your previous request was interrupted by an app restart. You may need to resend your last message."; - if (!string.IsNullOrEmpty(lastPrompt)) - { - var truncated = lastPrompt.Length > 80 ? lastPrompt[..80] + "…" : lastPrompt; - interruptMsg += $"\n📝 Last message: \"{truncated}\""; - } - state.Info.History.Add(ChatMessage.SystemMessage(interruptMsg)); - OnError?.Invoke(displayName, "Session was interrupted by app restart — previous turn lost"); - OnStateChanged?.Invoke(); - } - }); - }); - } - copilotSession.On(evt => HandleSessionEvent(state, evt)); + } if (!_sessions.TryAdd(displayName, state)) { From 62fbb5bfe34809afce52896ad64ac2bc5d8a417a Mon Sep 17 00:00:00 2001 From: Shane Date: Thu, 19 Feb 2026 14:28:19 -0600 Subject: [PATCH 10/15] Fix watchdog using 120s timeout instead of 600s during tool-call loops The processing watchdog was incorrectly using the 120s inactivity timeout even when the session was actively running multi-turn tool calls. This happened because AssistantTurnStartEvent resets ActiveToolCallCount to 0 between tool rounds, making the model's 'thinking' gap between tools look like inactivity. Added HasUsedToolsThisTurn flag that stays true for the entire processing cycle once any tool executes. The watchdog now uses the 600s tool timeout when: a tool is actively running (hasActiveTool), the session was resumed mid-turn (IsResumed), or tools have been used this turn (HasUsedToolsThisTurn). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/CopilotService.Events.cs | 17 +++++++++++++++-- PolyPilot/Services/CopilotService.cs | 5 +++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 2b6dafda38..035d300867 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); + state.HasUsedToolsThisTurn = true; var startToolName = toolStart.Data.ToolName ?? "unknown"; var startCallId = toolStart.Data.ToolCallId ?? ""; var toolInput = ExtractToolInput(toolStart.Data); @@ -647,6 +648,7 @@ private void CompleteResponse(SessionState state, long? expectedGeneration = nul $"(responseLen={state.CurrentResponse.Length}, thread={Environment.CurrentManagedThreadId})"); CancelProcessingWatchdog(state); + state.HasUsedToolsThisTurn = false; var response = state.CurrentResponse.ToString(); if (!string.IsNullOrEmpty(response)) { @@ -1100,13 +1102,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 || 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. @@ -1125,6 +1137,7 @@ private async Task RunProcessingWatchdogAsync(SessionState state, string session } CancelProcessingWatchdog(state); Interlocked.Exchange(ref state.ActiveToolCallCount, 0); + state.HasUsedToolsThisTurn = 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.cs b/PolyPilot/Services/CopilotService.cs index e15bbb30a6..24593c5b1b 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 From 6c3436d35d41f94ffdedd32a9b1c86fa653a21c2 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 15:54:40 -0600 Subject: [PATCH 11/15] Add tests for watchdog timeout selection logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 7 tests to ProcessingWatchdogTests covering the 3-way condition that selects between inactivity (120s) and tool execution (600s) timeouts: - HasUsedToolsThisTurn: defaults false, can be set, reset by CompleteResponse - Timeout selection: no tools → 120s, active tool → 600s, resumed → 600s, has-used-tools → 600s SessionState is private, so tests replicate the watchdog's decision logic inline using local variables matching CopilotService.Events.cs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ProcessingWatchdogTests.cs | 108 +++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/PolyPilot.Tests/ProcessingWatchdogTests.cs b/PolyPilot.Tests/ProcessingWatchdogTests.cs index 6c1362c0e6..c9a9a687da 100644 --- a/PolyPilot.Tests/ProcessingWatchdogTests.cs +++ b/PolyPilot.Tests/ProcessingWatchdogTests.cs @@ -810,4 +810,112 @@ 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); + } } From 0adc871f339440a0dac09bba01f8adfc2a915c85 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 15:55:28 -0600 Subject: [PATCH 12/15] Document watchdog architecture, diagnostic tags, and thread safety patterns Add to copilot-instructions.md: - Processing Watchdog section (120s/600s timeout tiers, 3-way condition) - Diagnostic Log Tags reference ([SEND], [COMPLETE], [ERROR], etc.) - Thread Safety: IsProcessing Mutations (InvokeOnUI pattern) - LastPrompt in session persistence - 11 missing test files in Test Coverage section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 46 ++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) 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. From 11f183383a7166d93aa56b5f72487f77b6348855 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 15:59:10 -0600 Subject: [PATCH 13/15] Skip whitespace-only assistant messages in FlushCurrentResponse and CompleteResponse FlushCurrentResponse was creating ghost messages containing only newlines when the CLI sent formatting whitespace before tool calls. Changed both FlushCurrentResponse and CompleteResponse to use IsNullOrWhiteSpace instead of IsNullOrEmpty. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/CopilotService.Events.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 035d300867..af8f7adfbd 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -603,7 +603,7 @@ 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) { Model = state.Info.Model }; state.Info.History.Add(msg); @@ -650,7 +650,7 @@ private void CompleteResponse(SessionState state, long? expectedGeneration = nul CancelProcessingWatchdog(state); state.HasUsedToolsThisTurn = false; var response = state.CurrentResponse.ToString(); - if (!string.IsNullOrEmpty(response)) + if (!string.IsNullOrWhiteSpace(response)) { var msg = new ChatMessage("assistant", response, DateTime.Now) { Model = state.Info.Model }; state.Info.History.Add(msg); From 4781fb5ce1f3cc076d244a04220741315afbca12 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 16:20:43 -0600 Subject: [PATCH 14/15] Fix 3 review findings: lastPrompt, HasUsedToolsThisTurn reset, IsResumed scoping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Wire lastPrompt into reconnect system message when session was mid-turn — shows truncated last user message so user knows what was interrupted. 2. Reset HasUsedToolsThisTurn in SendPromptAsync and AbortSessionAsync alongside ActiveToolCallCount — prevents stale tool flag from inflating watchdog timeout on subsequent turns. 3. Scope IsResumed to mid-turn resumes only (set when isStillProcessing is true, not unconditionally). Clear it in CompleteResponse after first successful turn. Changed from init to set property. 3 new tests (680 total). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ProcessingWatchdogTests.cs | 57 +++++++++++++++++++++ PolyPilot/Models/AgentSessionInfo.cs | 2 +- PolyPilot/Services/CopilotService.Events.cs | 1 + PolyPilot/Services/CopilotService.cs | 12 ++++- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/PolyPilot.Tests/ProcessingWatchdogTests.cs b/PolyPilot.Tests/ProcessingWatchdogTests.cs index c9a9a687da..71a7a6ea10 100644 --- a/PolyPilot.Tests/ProcessingWatchdogTests.cs +++ b/PolyPilot.Tests/ProcessingWatchdogTests.cs @@ -918,4 +918,61 @@ public void WatchdogTimeoutSelection_HasUsedTools_UsesToolTimeout() 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); + } } 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/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index af8f7adfbd..9c96aeb9d7 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -649,6 +649,7 @@ private void CompleteResponse(SessionState state, long? expectedGeneration = nul CancelProcessingWatchdog(state); state.HasUsedToolsThisTurn = false; + state.Info.IsResumed = false; // Clear after first successful turn var response = state.CurrentResponse.ToString(); if (!string.IsNullOrWhiteSpace(response)) { diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 24593c5b1b..397ee1202b 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1113,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); @@ -1145,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); @@ -1153,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)); @@ -1447,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(); @@ -1610,6 +1617,7 @@ public async Task AbortSessionAsync(string sessionName) Debug($"[ABORT] '{sessionName}' user abort, clearing IsProcessing"); state.Info.IsProcessing = false; Interlocked.Exchange(ref state.ActiveToolCallCount, 0); + state.HasUsedToolsThisTurn = false; CancelProcessingWatchdog(state); state.ResponseCompletion?.TrySetCanceled(); OnStateChanged?.Invoke(); From 38ef553893c0253c14b8c4add7f7f6e33871fa01 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 19 Feb 2026 16:26:43 -0600 Subject: [PATCH 15/15] Fix IsResumed leak and HasUsedToolsThisTurn volatile safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fleet review found 2 issues: 1. IsResumed not cleared on abort/error/watchdog paths (GPT-5.3 + Gemini). Only CompleteResponse cleared it, so if a resumed session ended via abort, error, or watchdog timeout, all subsequent turns permanently used the 600s tool timeout instead of 120s inactivity timeout. Fix: Clear IsResumed in abort, error, and watchdog paths. 2. HasUsedToolsThisTurn missing volatile barrier (Sonnet). Written on SDK background thread, read on watchdog timer thread. ARM weak memory model could see stale false, causing premature 120s timeout during tool execution. Fix: Use Volatile.Write on set, Volatile.Read on watchdog check — consistent with HasReceivedEventsSinceResume pattern. 4 new tests (684 total). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ProcessingWatchdogTests.cs | 62 +++++++++++++++++++++ PolyPilot/Services/CopilotService.Events.cs | 6 +- PolyPilot/Services/CopilotService.cs | 1 + 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/PolyPilot.Tests/ProcessingWatchdogTests.cs b/PolyPilot.Tests/ProcessingWatchdogTests.cs index 71a7a6ea10..52c2d6b1a1 100644 --- a/PolyPilot.Tests/ProcessingWatchdogTests.cs +++ b/PolyPilot.Tests/ProcessingWatchdogTests.cs @@ -975,4 +975,66 @@ public void IsResumed_OnlySetWhenStillProcessing() 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/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index 9c96aeb9d7..e44ca5766a 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -275,7 +275,7 @@ void Invoke(Action action) case ToolExecutionStartEvent toolStart: if (toolStart.Data == null) break; Interlocked.Increment(ref state.ActiveToolCallCount); - state.HasUsedToolsThisTurn = true; + Volatile.Write(ref state.HasUsedToolsThisTurn, true); var startToolName = toolStart.Data.ToolName ?? "unknown"; var startCallId = toolStart.Data.ToolCallId ?? ""; var toolInput = ExtractToolInput(toolStart.Data); @@ -501,6 +501,7 @@ await notifService.SendNotificationAsync( 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; @@ -1110,7 +1111,7 @@ private async Task RunProcessingWatchdogAsync(SessionState state, string session // 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 || state.HasUsedToolsThisTurn; + var useToolTimeout = hasActiveTool || state.Info.IsResumed || Volatile.Read(ref state.HasUsedToolsThisTurn); var effectiveTimeout = useToolTimeout ? WatchdogToolExecutionTimeoutSeconds : WatchdogInactivityTimeoutSeconds; @@ -1139,6 +1140,7 @@ 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.cs b/PolyPilot/Services/CopilotService.cs index 397ee1202b..7d98d98e35 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -1616,6 +1616,7 @@ public async Task AbortSessionAsync(string sessionName) 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);