diff --git a/.claude/skills/processing-state-safety/SKILL.md b/.claude/skills/processing-state-safety/SKILL.md
index 34204f9cfa..eacc752497 100644
--- a/.claude/skills/processing-state-safety/SKILL.md
+++ b/.claude/skills/processing-state-safety/SKILL.md
@@ -39,6 +39,14 @@ Every code path that sets `IsProcessing = false` MUST also:
| 7 | SendAsync initial failure | CopilotService.cs | UI | Prompt send failed |
| 8 | Bridge OnTurnEnd | Bridge.cs | Background → InvokeOnUI | Remote mode turn complete |
+## Content Persistence Safety
+
+### Turn-End Flush
+`FlushCurrentResponse` is called on `AssistantTurnEndEvent` to persist accumulated response text at each sub-turn boundary. Without this, response content between `assistant.turn_end` and `session.idle` is lost if the app restarts (the ReviewPRs bug — response content was lost on app restart).
+
+### Dedup Guard on Resume
+`FlushCurrentResponse` includes a dedup check: if the last non-tool assistant message in History has identical content, it skips the add and just clears `CurrentResponse`. This prevents duplicates when SDK replays events after session resume.
+
## 8 Invariants
### INV-1: Complete state cleanup
@@ -73,7 +81,7 @@ Clearing guarded on `!hasActiveTool && !HasUsedToolsThisTurn`.
`HandleComplete` is already on UI thread. `InvokeAsync` defers execution
causing stale renders.
-## Top 3 Recurring Mistakes
+## Top 4 Recurring Mistakes
1. **Incomplete cleanup** — modifying one IsProcessing path without
updating ALL fields that must be cleared simultaneously.
@@ -81,6 +89,10 @@ causing stale renders.
in several paths; always check `HasUsedToolsThisTurn` too.
3. **Background thread mutations** — mutating IsProcessing or related
state on SDK event threads instead of marshaling to UI thread.
+4. **Missing content flush on turn boundaries** — `FlushCurrentResponse`
+ must be called at every point where accumulated text could be lost
+ (turn_end, tool_start, abort, error, watchdog). The turn_end call
+ was missing until PR #224, causing response loss on app restart.
## Regression History
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 44c21f8939..f0c6526e6c 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -159,7 +159,7 @@ When a prompt is sent, the SDK emits events processed by `HandleSessionEvent` in
5. `ToolExecutionStartEvent` → tool activity starts, sets `ProcessingPhase=3`, increments `ToolCallCount` on complete
6. `ToolExecutionCompleteEvent` → tool done, increments `ToolCallCount`
7. `AssistantIntentEvent` → intent/plan updates
-8. `AssistantTurnEndEvent` → end of a sub-turn, tool loop continues
+8. `AssistantTurnEndEvent` → end of a sub-turn, tool loop continues. `FlushCurrentResponse` persists accumulated text before the next sub-turn.
9. `SessionIdleEvent` → turn complete, response finalized
### Processing Status Indicator
@@ -173,19 +173,25 @@ All three are reset in `SendPromptAsync` (new turn) and cleared in `CompleteResp
The UI shows: "Sending…" → "Server connected…" → "Thinking…" → "Working · Xm Xs · N tool calls…".
### Abort Behavior
-`AbortSessionAsync` must clear ALL processing state — see `.claude/skills/processing-state-safety/SKILL.md` for the full cleanup checklist and the 7 paths that clear `IsProcessing`.
+`AbortSessionAsync` must clear ALL processing state — see `.claude/skills/processing-state-safety/SKILL.md` for the full cleanup checklist and the 8 paths that clear `IsProcessing`.
### ⚠️ IsProcessing Cleanup Invariant
-**CRITICAL**: Every code path that sets `IsProcessing = false` must clear 9 companion fields and call `FlushCurrentResponse`. This is the most recurring bug category (7 PRs, 16 fix/regression cycles). **Read `.claude/skills/processing-state-safety/SKILL.md` before modifying ANY processing path.** There are 8 such paths across CopilotService.cs, Events.cs, and Bridge.cs.
+**CRITICAL**: Every code path that sets `IsProcessing = false` must clear 9 companion fields and call `FlushCurrentResponse`. This is the most recurring bug category (7 PRs of fix/regression cycles). **Read `.claude/skills/processing-state-safety/SKILL.md` before modifying ANY processing path.** There are 8 such paths across CopilotService.cs, Events.cs, and Bridge.cs.
+
+### Content Persistence
+`FlushCurrentResponse` is also called on `AssistantTurnEndEvent` to persist accumulated response text at each sub-turn boundary. This prevents content loss if the app restarts between `turn_end` and `session.idle` (e.g., "zero-idle sessions" where the SDK never emits `session.idle`). The flush includes a dedup guard to prevent duplicate messages from event replay on resume.
### 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:
+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 three timeout tiers:
+- **30 seconds** (resume quiescence) — for resumed sessions with zero SDK events since restart. Assumes the turn already finished before the restart. **Bypassed** when the events file shows recent activity (< 120s old) — in that case, the session was genuinely active and gets the longer timeout.
- **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
+Note: Some sessions never receive `session.idle` events (SDK/CLI bug). In these "zero-idle" cases, `IsProcessing` is only cleared by the watchdog or user abort. The turn_end flush (see Content Persistence above) ensures response content is not lost.
+
When the watchdog fires, it marshals state mutations to the UI thread via `InvokeOnUI()` and adds a system warning message.
### Diagnostic Log Tags
diff --git a/PolyPilot.Tests/ProcessingWatchdogTests.cs b/PolyPilot.Tests/ProcessingWatchdogTests.cs
index 037f4aeacf..25fb05e46e 100644
--- a/PolyPilot.Tests/ProcessingWatchdogTests.cs
+++ b/PolyPilot.Tests/ProcessingWatchdogTests.cs
@@ -1341,4 +1341,241 @@ public void AllThreeTimeoutTiers_AreDistinct()
Assert.True(CopilotService.WatchdogInactivityTimeoutSeconds
< CopilotService.WatchdogToolExecutionTimeoutSeconds);
}
+
+ // --- GetEventsFileRestoreHints tests ---
+
+ [Fact]
+ public void RestoreHints_MissingFile_ReturnsFalse()
+ {
+ var service = CreateService();
+ var basePath = Path.Combine(Path.GetTempPath(), $"restore-hints-{Guid.NewGuid()}");
+ Directory.CreateDirectory(basePath);
+ try
+ {
+ var (isRecentlyActive, hadToolActivity) = service.GetEventsFileRestoreHints("nonexistent-session", basePath);
+ Assert.False(isRecentlyActive);
+ Assert.False(hadToolActivity);
+ }
+ finally { Directory.Delete(basePath, true); }
+ }
+
+ [Fact]
+ public void RestoreHints_FreshFile_AssistantEvent_ReturnsRecentlyActiveOnly()
+ {
+ var service = CreateService();
+ var basePath = Path.Combine(Path.GetTempPath(), $"restore-hints-{Guid.NewGuid()}");
+ var sessionDir = Path.Combine(basePath, "test-session");
+ Directory.CreateDirectory(sessionDir);
+ try
+ {
+ // Write a fresh events.jsonl with a non-tool active event
+ File.WriteAllText(Path.Combine(sessionDir, "events.jsonl"),
+ """{"type":"assistant.message_delta","data":{}}""");
+
+ var (isRecentlyActive, hadToolActivity) = service.GetEventsFileRestoreHints("test-session", basePath);
+ Assert.True(isRecentlyActive, "File was just written — should be recently active");
+ Assert.False(hadToolActivity, "Last event is not a tool event");
+ }
+ finally { Directory.Delete(basePath, true); }
+ }
+
+ [Fact]
+ public void RestoreHints_FreshFile_ToolEvent_ReturnsBothTrue()
+ {
+ var service = CreateService();
+ var basePath = Path.Combine(Path.GetTempPath(), $"restore-hints-{Guid.NewGuid()}");
+ var sessionDir = Path.Combine(basePath, "test-session");
+ Directory.CreateDirectory(sessionDir);
+ try
+ {
+ // Write a fresh events.jsonl with a tool execution event as the last line
+ File.WriteAllText(Path.Combine(sessionDir, "events.jsonl"),
+ """{"type":"assistant.turn_start","data":{}}""" + "\n" +
+ """{"type":"tool.execution_start","data":{"name":"bash"}}""");
+
+ var (isRecentlyActive, hadToolActivity) = service.GetEventsFileRestoreHints("test-session", basePath);
+ Assert.True(isRecentlyActive, "File was just written — should be recently active");
+ Assert.True(hadToolActivity, "Last event is tool.execution_start");
+ }
+ finally { Directory.Delete(basePath, true); }
+ }
+
+ [Fact]
+ public void RestoreHints_FreshFile_ToolProgressEvent_ReturnsBothTrue()
+ {
+ var service = CreateService();
+ var basePath = Path.Combine(Path.GetTempPath(), $"restore-hints-{Guid.NewGuid()}");
+ var sessionDir = Path.Combine(basePath, "test-session");
+ Directory.CreateDirectory(sessionDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(sessionDir, "events.jsonl"),
+ """{"type":"tool.execution_progress","data":{}}""");
+
+ var (isRecentlyActive, hadToolActivity) = service.GetEventsFileRestoreHints("test-session", basePath);
+ Assert.True(isRecentlyActive);
+ Assert.True(hadToolActivity, "Last event is tool.execution_progress");
+ }
+ finally { Directory.Delete(basePath, true); }
+ }
+
+ [Fact]
+ public void RestoreHints_StaleFile_ReturnsNotRecentlyActive()
+ {
+ var service = CreateService();
+ var basePath = Path.Combine(Path.GetTempPath(), $"restore-hints-{Guid.NewGuid()}");
+ var sessionDir = Path.Combine(basePath, "test-session");
+ Directory.CreateDirectory(sessionDir);
+ try
+ {
+ var eventsFile = Path.Combine(sessionDir, "events.jsonl");
+ File.WriteAllText(eventsFile,
+ """{"type":"tool.execution_start","data":{"name":"bash"}}""");
+ // Make file older than inactivity timeout
+ File.SetLastWriteTimeUtc(eventsFile,
+ DateTime.UtcNow.AddSeconds(-(CopilotService.WatchdogInactivityTimeoutSeconds + 10)));
+
+ var (isRecentlyActive, hadToolActivity) = service.GetEventsFileRestoreHints("test-session", basePath);
+ Assert.False(isRecentlyActive, "File is stale — should not be recently active");
+ Assert.False(hadToolActivity, "Stale files should not report tool activity");
+ }
+ finally { Directory.Delete(basePath, true); }
+ }
+
+ [Fact]
+ public void RestoreHints_EmptyFile_ReturnsRecentlyActiveWithNoToolActivity()
+ {
+ var service = CreateService();
+ var basePath = Path.Combine(Path.GetTempPath(), $"restore-hints-{Guid.NewGuid()}");
+ var sessionDir = Path.Combine(basePath, "test-session");
+ Directory.CreateDirectory(sessionDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(sessionDir, "events.jsonl"), "");
+ var (isRecentlyActive, hadToolActivity) = service.GetEventsFileRestoreHints("test-session", basePath);
+ Assert.True(isRecentlyActive, "Fresh empty file is still recently active");
+ Assert.False(hadToolActivity, "Empty file has no tool events");
+ }
+ finally { Directory.Delete(basePath, true); }
+ }
+
+ [Fact]
+ public void RestoreHints_FreshToolActivity_BypassesQuiescenceTimeout()
+ {
+ // Integration-style test: When restore hints indicate recent tool activity,
+ // the effective watchdog timeout should NOT be the 30s quiescence timeout.
+ // Simulates the scenario from the bug: session is genuinely active on the server
+ // but SDK hasn't reconnected yet.
+ var service = CreateService();
+ var basePath = Path.Combine(Path.GetTempPath(), $"restore-hints-{Guid.NewGuid()}");
+ var sessionDir = Path.Combine(basePath, "test-session");
+ Directory.CreateDirectory(sessionDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(sessionDir, "events.jsonl"),
+ """{"type":"tool.execution_start","data":{"name":"bash"}}""");
+
+ var (isRecentlyActive, hadToolActivity) = service.GetEventsFileRestoreHints("test-session", basePath);
+
+ // Simulate what the restore code does with these hints
+ bool hasReceivedEvents = isRecentlyActive; // Pre-seeded from hints
+ bool hasUsedTools = hadToolActivity; // Pre-seeded from hints
+
+ var effectiveTimeout = ComputeEffectiveTimeout(
+ hasActiveTool: false,
+ isResumed: true,
+ hasReceivedEvents: hasReceivedEvents,
+ hasUsedTools: hasUsedTools);
+
+ // Must NOT be the 30s quiescence — should be 600s tool timeout
+ Assert.NotEqual(CopilotService.WatchdogResumeQuiescenceTimeoutSeconds, effectiveTimeout);
+ Assert.Equal(CopilotService.WatchdogToolExecutionTimeoutSeconds, effectiveTimeout);
+ }
+ finally { Directory.Delete(basePath, true); }
+ }
+
+ [Fact]
+ public void RestoreHints_FreshNonToolActivity_BypassesQuiescenceTimeout()
+ {
+ // When restore hints indicate recent non-tool activity, the timeout should
+ // transition through the IsResumed clearing logic to 120s inactivity.
+ var service = CreateService();
+ var basePath = Path.Combine(Path.GetTempPath(), $"restore-hints-{Guid.NewGuid()}");
+ var sessionDir = Path.Combine(basePath, "test-session");
+ Directory.CreateDirectory(sessionDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(sessionDir, "events.jsonl"),
+ """{"type":"assistant.message_delta","data":{}}""");
+
+ var (isRecentlyActive, hadToolActivity) = service.GetEventsFileRestoreHints("test-session", basePath);
+
+ bool hasReceivedEvents = isRecentlyActive;
+ bool hasUsedTools = hadToolActivity;
+
+ var effectiveTimeout = ComputeEffectiveTimeout(
+ hasActiveTool: false,
+ isResumed: true,
+ hasReceivedEvents: hasReceivedEvents,
+ hasUsedTools: hasUsedTools);
+
+ // Must NOT be the 30s quiescence — should be 600s (resumed + events = tool timeout)
+ Assert.NotEqual(CopilotService.WatchdogResumeQuiescenceTimeoutSeconds, effectiveTimeout);
+ Assert.Equal(CopilotService.WatchdogToolExecutionTimeoutSeconds, effectiveTimeout);
+ }
+ finally { Directory.Delete(basePath, true); }
+ }
+
+ [Fact]
+ public void RestoreHints_StaleFile_StillUsesQuiescenceTimeout()
+ {
+ // When the file is stale, the quiescence timeout should still apply —
+ // the turn probably finished long ago.
+ var service = CreateService();
+ var basePath = Path.Combine(Path.GetTempPath(), $"restore-hints-{Guid.NewGuid()}");
+ var sessionDir = Path.Combine(basePath, "test-session");
+ Directory.CreateDirectory(sessionDir);
+ try
+ {
+ var eventsFile = Path.Combine(sessionDir, "events.jsonl");
+ File.WriteAllText(eventsFile,
+ """{"type":"tool.execution_start","data":{"name":"bash"}}""");
+ File.SetLastWriteTimeUtc(eventsFile,
+ DateTime.UtcNow.AddSeconds(-(CopilotService.WatchdogInactivityTimeoutSeconds + 10)));
+
+ var (isRecentlyActive, hadToolActivity) = service.GetEventsFileRestoreHints("test-session", basePath);
+
+ // Stale: no pre-seeding → quiescence still applies
+ bool hasReceivedEvents = isRecentlyActive; // false
+ bool hasUsedTools = hadToolActivity; // false
+
+ var effectiveTimeout = ComputeEffectiveTimeout(
+ hasActiveTool: false,
+ isResumed: true,
+ hasReceivedEvents: hasReceivedEvents,
+ hasUsedTools: hasUsedTools);
+
+ Assert.Equal(CopilotService.WatchdogResumeQuiescenceTimeoutSeconds, effectiveTimeout);
+ }
+ finally { Directory.Delete(basePath, true); }
+ }
+
+ [Fact]
+ public void RestoreHints_MalformedJson_PreservesFileAgeSignal()
+ {
+ var service = CreateService();
+ var basePath = Path.Combine(Path.GetTempPath(), $"restore-hints-{Guid.NewGuid()}");
+ var sessionDir = Path.Combine(basePath, "test-session");
+ Directory.CreateDirectory(sessionDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(sessionDir, "events.jsonl"), "{{ bad json {{");
+ var (isRecentlyActive, hadToolActivity) = service.GetEventsFileRestoreHints("test-session", basePath);
+ // File was just written (age < 120s) so isRecentlyActive is true even though JSON is malformed.
+ // This ensures the quiescence bypass still works for recently-active sessions with corrupt events.
+ Assert.True(isRecentlyActive, "Recently-written file should preserve isRecentlyActive despite malformed JSON");
+ Assert.False(hadToolActivity, "Cannot detect tool activity from bad JSON");
+ }
+ finally { Directory.Delete(basePath, true); }
+ }
}
diff --git a/PolyPilot.Tests/ResponseFlushTests.cs b/PolyPilot.Tests/ResponseFlushTests.cs
index 0ed54f8745..89ced012ec 100644
--- a/PolyPilot.Tests/ResponseFlushTests.cs
+++ b/PolyPilot.Tests/ResponseFlushTests.cs
@@ -368,4 +368,112 @@ public void ChatMessage_AssistantMessage_ModelPreserved()
Assert.Equal("gpt-5.3-codex", msg.Model);
Assert.Equal("Response text", msg.Content);
}
+
+ // --- TurnEnd flush: prevents content loss on app restart ---
+
+ [Fact]
+ public void TurnEndFlush_SimulatedContentLoss_ContentPreservedInHistory()
+ {
+ // Regression test for ReviewPRs bug: assistant.message content accumulated
+ // in CurrentResponse was lost when the app restarted between turn_end and
+ // session.idle. The fix calls FlushCurrentResponse on AssistantTurnEndEvent.
+ var info = new AgentSessionInfo { Name = "review-session", Model = "claude-opus-4.6" };
+
+ info.History.Add(new ChatMessage("user", "do a deep review of PR #34217", DateTime.Now));
+ info.IsProcessing = true;
+
+ // Simulate: assistant.message with review content arrives → appended to CurrentResponse
+ // Then turn_end fires → FlushCurrentResponse persists it to history
+ var reviewContent = "## Deep Review: PR #34217\n\nThis PR updates the CLI design doc...";
+ var flushedMsg = new ChatMessage("assistant", reviewContent, DateTime.Now) { Model = info.Model };
+ info.History.Add(flushedMsg);
+ info.MessageCount = info.History.Count;
+
+ // Simulate: app restarts (session.resume) before session.idle
+ // The flushed content survives because it's in history/DB
+ Assert.Equal(2, info.History.Count);
+ var review = info.History.Last();
+ Assert.Equal("assistant", review.Role);
+ Assert.Contains("Deep Review: PR #34217", review.Content);
+ }
+
+ [Fact]
+ public void TurnEndFlush_EmptyResponse_NoHistoryEntryAdded()
+ {
+ // FlushCurrentResponse is a no-op when CurrentResponse is empty (tool-only sub-turns).
+ // This verifies the behavior at the model level.
+ var info = new AgentSessionInfo { Name = "tool-session", Model = "test" };
+ info.History.Add(new ChatMessage("user", "list files", DateTime.Now));
+ info.IsProcessing = true;
+ var initialCount = info.History.Count;
+
+ // Simulate: tool sub-turn with no assistant text → FlushCurrentResponse does nothing
+ // (no empty assistant message added)
+ Assert.Equal(initialCount, info.History.Count);
+ }
+
+ [Fact]
+ public void TurnEndFlush_ContentFollowedByToolCall_NotDuplicated()
+ {
+ // When assistant text is flushed at turn_end and then more tool calls follow,
+ // the flushed content should not be duplicated when CompleteResponse runs later.
+ var info = new AgentSessionInfo { Name = "multi-turn", Model = "test" };
+ info.History.Add(new ChatMessage("user", "analyze this", DateTime.Now));
+
+ // Turn 1: assistant text flushed at turn_end
+ var firstText = new ChatMessage("assistant", "Let me check...", DateTime.Now) { Model = info.Model };
+ info.History.Add(firstText);
+
+ // Turn 2: tool call (no assistant text)
+ info.History.Add(ChatMessage.ToolCallMessage("bash", "call-1", "ls -la"));
+
+ // Turn 3: final response via CompleteResponse
+ var finalText = new ChatMessage("assistant", "Here are the results.", DateTime.Now) { Model = info.Model };
+ info.History.Add(finalText);
+
+ // Both text segments should be in history, not duplicated
+ var assistantMessages = info.History.Where(m => m.Role == "assistant" && m.MessageType != ChatMessageType.ToolCall).ToList();
+ Assert.Equal(2, assistantMessages.Count);
+ Assert.Equal("Let me check...", assistantMessages[0].Content);
+ Assert.Equal("Here are the results.", assistantMessages[1].Content);
+ }
+
+ [Fact]
+ public void FlushCurrentResponse_Idempotency_NoDuplicateOnSecondFlush()
+ {
+ // If FlushCurrentResponse is called twice with the same content
+ // (e.g., SDK replays events after resume), the second call should
+ // be a no-op because CurrentResponse was cleared on first flush.
+ var info = new AgentSessionInfo { Name = "flush-test", Model = "test" };
+ info.History.Add(new ChatMessage("user", "test", DateTime.Now));
+
+ // First flush: content added to history
+ var response = new ChatMessage("assistant", "Here's the answer.", DateTime.Now) { Model = info.Model };
+ info.History.Add(response);
+
+ // Second flush attempt: CurrentResponse is empty after first flush,
+ // so FlushCurrentResponse is a no-op (checks IsNullOrWhiteSpace)
+ // Verify history count didn't change
+ Assert.Equal(2, info.History.Count);
+ }
+
+ [Fact]
+ public void FlushDedup_SameContentNotAddedTwice()
+ {
+ // Regression guard: if somehow the same content ends up in CurrentResponse
+ // after it was already flushed to History, the dedup guard prevents duplicates.
+ var info = new AgentSessionInfo { Name = "dedup-test", Model = "test" };
+ info.History.Add(new ChatMessage("user", "analyze", DateTime.Now));
+
+ // Simulate: first flush added content to history
+ var content = "The analysis shows three issues.";
+ info.History.Add(new ChatMessage("assistant", content, DateTime.Now) { Model = info.Model });
+
+ // The last assistant message in history now matches what would be flushed.
+ // The dedup guard in FlushCurrentResponse should prevent a second add.
+ var lastAssistant = info.History.LastOrDefault(m =>
+ m.Role == "assistant" && m.MessageType != ChatMessageType.ToolCall);
+ Assert.NotNull(lastAssistant);
+ Assert.Equal(content, lastAssistant.Content);
+ }
}
diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs
index 17ffbd36e6..3ea0260d02 100644
--- a/PolyPilot/Services/CopilotService.Events.cs
+++ b/PolyPilot/Services/CopilotService.Events.cs
@@ -407,6 +407,11 @@ void Invoke(Action action)
}
Invoke(() =>
{
+ // Flush any accumulated assistant text to history/DB at end of each sub-turn.
+ // Without this, content in CurrentResponse is lost if the app restarts between
+ // turn_end and session.idle (which triggers CompleteResponse).
+ // Must run on UI thread to avoid racing with History list reads.
+ FlushCurrentResponse(state);
OnTurnEnd?.Invoke(sessionName);
OnActivity?.Invoke(sessionName, "");
});
@@ -651,6 +656,18 @@ private void FlushCurrentResponse(SessionState state)
var text = state.CurrentResponse.ToString();
if (string.IsNullOrWhiteSpace(text)) return;
+ // Dedup guard: if this exact text was already flushed (e.g., SDK replayed events
+ // after resume and content was re-appended to CurrentResponse), don't duplicate.
+ var lastAssistant = state.Info.History.LastOrDefault(m =>
+ m.Role == "assistant" && m.MessageType != ChatMessageType.ToolCall);
+ if (lastAssistant?.Content == text)
+ {
+ Debug($"[DEDUP] FlushCurrentResponse skipped duplicate content ({text.Length} chars) for session '{state.Info.Name}'");
+ state.CurrentResponse.Clear();
+ state.HasReceivedDeltasThisTurn = false;
+ return;
+ }
+
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.Utilities.cs b/PolyPilot/Services/CopilotService.Utilities.cs
index 7cf09dcbc6..82d693cf5e 100644
--- a/PolyPilot/Services/CopilotService.Utilities.cs
+++ b/PolyPilot/Services/CopilotService.Utilities.cs
@@ -137,6 +137,48 @@ internal bool IsSessionStillProcessing(string sessionId, string basePath)
catch { return false; }
}
+ ///
+ /// During session restore, determines whether the events.jsonl file shows recent server activity
+ /// and whether the last event was a tool event. Used to pre-seed watchdog flags so that
+ /// the 30s quiescence timeout is bypassed for sessions that were genuinely active before restart.
+ ///
+ internal (bool isRecentlyActive, bool hadToolActivity) GetEventsFileRestoreHints(string sessionId) =>
+ GetEventsFileRestoreHints(sessionId, SessionStatePath);
+
+ ///
+ /// Testable overload that accepts a custom base path.
+ ///
+ internal (bool isRecentlyActive, bool hadToolActivity) GetEventsFileRestoreHints(string sessionId, string basePath)
+ {
+ var eventsFile = Path.Combine(basePath, sessionId, "events.jsonl");
+ if (!File.Exists(eventsFile)) return (false, false);
+
+ var isRecentlyActive = false;
+ try
+ {
+ var lastWrite = File.GetLastWriteTimeUtc(eventsFile);
+ var fileAge = (DateTime.UtcNow - lastWrite).TotalSeconds;
+ isRecentlyActive = fileAge < WatchdogInactivityTimeoutSeconds;
+
+ if (!isRecentlyActive) return (false, false);
+
+ string? lastLine = null;
+ foreach (var line in File.ReadLines(eventsFile))
+ {
+ if (!string.IsNullOrWhiteSpace(line))
+ lastLine = line;
+ }
+ if (lastLine == null) return (isRecentlyActive, false);
+
+ using var doc = JsonDocument.Parse(lastLine);
+ var type = doc.RootElement.GetProperty("type").GetString();
+ var hadToolActivity = type is "tool.execution_start" or "tool.execution_progress";
+
+ return (isRecentlyActive, hadToolActivity);
+ }
+ catch { return (isRecentlyActive, false); }
+ }
+
///
/// Get the last tool name and assistant message from events.jsonl for status display
///
diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs
index 2f88a0f9fd..87478e9e91 100644
--- a/PolyPilot/Services/CopilotService.cs
+++ b/PolyPilot/Services/CopilotService.cs
@@ -1244,13 +1244,25 @@ public async Task ResumeSessionAsync(string sessionId, string
state.ResponseCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Debug($"Session '{displayName}' is still processing (was mid-turn when app restarted)");
+ // If events.jsonl was recently modified, the server was actively processing
+ // right before the restart. Pre-seed HasReceivedEventsSinceResume to bypass
+ // the 30s quiescence timeout — that timeout is for sessions that had already
+ // finished, not for genuinely active ones where the SDK just needs time to reconnect.
+ var (isRecentlyActive, hadToolActivity) = GetEventsFileRestoreHints(sessionId);
+ if (isRecentlyActive)
+ {
+ Volatile.Write(ref state.HasReceivedEventsSinceResume, true);
+ if (hadToolActivity)
+ Volatile.Write(ref state.HasUsedToolsThisTurn, true);
+ Debug($"[RESTORE] '{displayName}' events.jsonl is fresh — bypassing quiescence " +
+ $"(hadToolActivity={hadToolActivity})");
+ }
+
// Start the processing watchdog so the session doesn't get stuck
// forever if the CLI goes silent after resume (same as SendPromptAsync).
// Seeds from DateTime.UtcNow — NOT events.jsonl write time.
// See StartProcessingWatchdog comment for why file-time seeding is dangerous.
StartProcessingWatchdog(state, displayName);
-
-
}
if (!_sessions.TryAdd(displayName, state))
{