diff --git a/PolyPilot.IntegrationTests/OrchestrationRecoveryTests.cs b/PolyPilot.IntegrationTests/OrchestrationRecoveryTests.cs
new file mode 100644
index 0000000000..2478357c39
--- /dev/null
+++ b/PolyPilot.IntegrationTests/OrchestrationRecoveryTests.cs
@@ -0,0 +1,45 @@
+using PolyPilot.IntegrationTests.Fixtures;
+
+namespace PolyPilot.IntegrationTests;
+
+///
+/// Integration tests for orchestration recovery paths (issue #387).
+/// Verifies multi-agent UI elements exist and orchestration features
+/// are accessible through the live Blazor UI via DevFlow CDP.
+///
+[Collection("PolyPilot")]
+[Trait("Category", "OrchestrationRecovery")]
+public class OrchestrationRecoveryTests : IntegrationTestBase
+{
+ public OrchestrationRecoveryTests(AppFixture app, ITestOutputHelper output)
+ : base(app, output) { }
+
+ [Fact]
+ public async Task Dashboard_LoadsSuccessfully()
+ {
+ await WaitForCdpReadyAsync();
+ var exists = await ExistsAsync("#dashboard");
+ Assert.True(exists, "Dashboard page should load and contain #dashboard element");
+ }
+
+ [Fact]
+ public async Task Dashboard_NewSessionButtonExists()
+ {
+ await WaitForCdpReadyAsync();
+ var exists = await ExistsAsync(".new-session-btn, #new-session-btn, [data-testid='new-session']");
+ Assert.True(exists, "New session button should be present on the dashboard");
+ }
+
+ [Fact]
+ public async Task Settings_ConnectionModeExists()
+ {
+ await WaitForCdpReadyAsync();
+ var navigated = await NavigateToAsync("Settings", "#settings-page");
+ Assert.True(navigated, "Expected to navigate to Settings page");
+ await ScreenshotAsync("settings-page");
+ // Verify settings page has connection mode options
+ var settingsContent = await GetTextAsync("#settings-page");
+ Assert.False(string.IsNullOrWhiteSpace(settingsContent),
+ "Settings page should have visible content");
+ }
+}
diff --git a/PolyPilot.Tests/OrchestrationRecoveryBehavioralTests.cs b/PolyPilot.Tests/OrchestrationRecoveryBehavioralTests.cs
new file mode 100644
index 0000000000..4c648138d5
--- /dev/null
+++ b/PolyPilot.Tests/OrchestrationRecoveryBehavioralTests.cs
@@ -0,0 +1,1308 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using PolyPilot.Models;
+using PolyPilot.Services;
+
+namespace PolyPilot.Tests;
+
+///
+/// Behavioral tests for orchestration recovery paths (issue #387).
+/// These tests exercise the actual recovery logic with real objects and events,
+/// replacing the structural tests that only verify source code patterns.
+///
+/// Coverage:
+/// 1. LoadHistoryFromDiskAsync — events.jsonl parsing with timestamps and filtering
+/// 2. bestResponse multi-round accumulation — longest-content-wins across recovery rounds
+/// 3. PrematureIdleSignal lifecycle — ManualResetEventSlim set/reset/wait/dispose
+/// 4. OnSessionComplete event handler — subscription, firing, and cleanup
+/// 5. OCE handling — CancellationTokenSource cancellation preserves bestResponse
+/// 6. dispatchTime filtering — DateTimeOffset-based message filtering
+///
+[Collection("BaseDir")]
+public class OrchestrationRecoveryBehavioralTests
+{
+ private readonly StubChatDatabase _chatDb = new();
+ private readonly StubServerManager _serverManager = new();
+ private readonly StubWsBridgeClient _bridgeClient = new();
+ private readonly StubDemoService _demoService = new();
+ private readonly IServiceProvider _serviceProvider;
+
+ private static readonly BindingFlags NonPublic = BindingFlags.NonPublic | BindingFlags.Instance;
+ private static readonly BindingFlags NonPublicStatic = BindingFlags.NonPublic | BindingFlags.Static;
+ private static readonly BindingFlags AnyInstance = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
+
+ public OrchestrationRecoveryBehavioralTests()
+ {
+ _serviceProvider = new ServiceCollection().BuildServiceProvider();
+ }
+
+ private CopilotService CreateService() =>
+ new CopilotService(_chatDb, _serverManager, _bridgeClient, new RepoManager(), _serviceProvider, _demoService);
+
+ #region Helpers
+
+ /// Get the SessionStatePath used by CopilotService (redirected to test temp dir).
+ private static string GetSessionStatePath()
+ {
+ var prop = typeof(CopilotService).GetProperty("SessionStatePath", NonPublicStatic)!;
+ return (string)prop.GetValue(null)!;
+ }
+
+ /// Create an events.jsonl file for a given session ID in the test directory.
+ private static string CreateEventsFile(string sessionId, params string[] lines)
+ {
+ var sessionDir = Path.Combine(GetSessionStatePath(), sessionId);
+ Directory.CreateDirectory(sessionDir);
+ var eventsFile = Path.Combine(sessionDir, "events.jsonl");
+ File.WriteAllLines(eventsFile, lines);
+ return eventsFile;
+ }
+
+ /// Invoke the private LoadHistoryFromDiskAsync method via reflection.
+ private static async Task> InvokeLoadHistoryFromDiskAsync(CopilotService svc, string sessionId)
+ {
+ var method = typeof(CopilotService).GetMethod("LoadHistoryFromDiskAsync", NonPublic)!;
+ var task = (Task>)method.Invoke(svc, new object[] { sessionId })!;
+ return await task;
+ }
+
+ /// Get the _sessions ConcurrentDictionary via reflection.
+ private static object GetSessionsDict(CopilotService svc)
+ {
+ var field = typeof(CopilotService).GetField("_sessions", NonPublic)!;
+ return field.GetValue(svc)!;
+ }
+
+ /// Get the SessionState type (private nested class).
+ private static Type GetSessionStateType()
+ {
+ return typeof(CopilotService).GetNestedType("SessionState", BindingFlags.NonPublic)!;
+ }
+
+ /// Create a SessionState with the given AgentSessionInfo via reflection.
+ /// GetUninitializedObject bypasses constructors, so readonly field initializers
+ /// (like PrematureIdleSignal = new ManualResetEventSlim()) don't run.
+ /// We manually initialize them after creation.
+ ///
+ /// Fields initialized here (required by tests or called by production methods):
+ /// - PrematureIdleSignal: used directly by §3 lifecycle tests
+ /// - CurrentResponse: StringBuilder, used by FlushCurrentResponse/CompleteResponse
+ /// - FlushedResponse: StringBuilder, used by CompleteResponse
+ /// - PendingReasoningMessages: ConcurrentDictionary, used by reasoning event handler
+ /// If future tests call production methods on this state and hit NRE,
+ /// check whether additional fields need initialization here.
+ private static object CreateSessionState(AgentSessionInfo info)
+ {
+ var stateType = GetSessionStateType();
+ var state = System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(stateType);
+ stateType.GetProperty("Info")!.SetValue(state, info);
+
+ // Initialize readonly fields that would normally be set by field initializers.
+ var signalField = stateType.GetField("PrematureIdleSignal", AnyInstance)!;
+ signalField.SetValue(state, new ManualResetEventSlim(initialState: false));
+
+ // Initialize other readonly fields to prevent NullReferenceException if
+ // production methods are called on this state in future tests.
+ // These are get-only auto-properties, so we set their backing fields directly.
+ var currentResponseField = stateType.GetField("k__BackingField", NonPublic);
+ currentResponseField?.SetValue(state, new StringBuilder());
+
+ var flushedResponseField = stateType.GetField("k__BackingField", NonPublic);
+ flushedResponseField?.SetValue(state, new StringBuilder());
+
+ var pendingReasoningField = stateType.GetField("k__BackingField", NonPublic);
+ pendingReasoningField?.SetValue(state, new ConcurrentDictionary());
+
+ return state;
+ }
+
+ /// Add a session to the CopilotService._sessions dictionary.
+ private static void AddSession(CopilotService svc, string name, object sessionState)
+ {
+ var dict = GetSessionsDict(svc);
+ dict.GetType().GetMethod("TryAdd")!.Invoke(dict, new[] { name, sessionState });
+ }
+
+ /// Get PrematureIdleSignal from a SessionState.
+ private static ManualResetEventSlim GetPrematureIdleSignal(object sessionState)
+ {
+ var field = sessionState.GetType().GetField("PrematureIdleSignal", AnyInstance)!;
+ return (ManualResetEventSlim)field.GetValue(sessionState)!;
+ }
+
+ /// Build a JSONL event line with the given type, data, and timestamp.
+ private static string BuildEventLine(string type, object data, DateTimeOffset? timestamp = null)
+ {
+ var ts = timestamp ?? DateTimeOffset.UtcNow;
+ var obj = new { type, data, timestamp = ts.ToString("o") };
+ return JsonSerializer.Serialize(obj);
+ }
+
+ #endregion
+
+ #region 1. LoadHistoryFromDiskAsync — events.jsonl parsing
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_ParsesUserMessages()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+ var ts = DateTimeOffset.UtcNow.AddMinutes(-5);
+
+ CreateEventsFile(sessionId,
+ BuildEventLine("user.message", new { content = "Hello world" }, ts));
+
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+
+ Assert.Single(history);
+ Assert.Equal("user", history[0].Role);
+ Assert.Equal("Hello world", history[0].Content);
+ Assert.Equal(ChatMessageType.User, history[0].MessageType);
+ }
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_ParsesAssistantMessages()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+ var ts = DateTimeOffset.UtcNow.AddMinutes(-3);
+
+ CreateEventsFile(sessionId,
+ BuildEventLine("assistant.message", new { content = "Here is my response" }, ts));
+
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+
+ Assert.Single(history);
+ Assert.Equal("assistant", history[0].Role);
+ Assert.Equal("Here is my response", history[0].Content);
+ Assert.Equal(ChatMessageType.Assistant, history[0].MessageType);
+ }
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_ParsesAssistantMessageWithReasoning()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+ var ts = DateTimeOffset.UtcNow.AddMinutes(-2);
+
+ CreateEventsFile(sessionId,
+ BuildEventLine("assistant.message", new { content = "Final answer", reasoningText = "Let me think..." }, ts));
+
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+
+ Assert.Equal(2, history.Count);
+ // First: reasoning message
+ Assert.Equal(ChatMessageType.Reasoning, history[0].MessageType);
+ Assert.Equal("Let me think...", history[0].Content);
+ Assert.True(history[0].IsCollapsed);
+ Assert.True(history[0].IsComplete);
+ // Second: assistant text
+ Assert.Equal(ChatMessageType.Assistant, history[1].MessageType);
+ Assert.Equal("Final answer", history[1].Content);
+ }
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_ParsesToolExecutionStartAndComplete()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+ var ts = DateTimeOffset.UtcNow.AddMinutes(-1);
+
+ CreateEventsFile(sessionId,
+ BuildEventLine("tool.execution_start",
+ new { toolName = "bash", toolCallId = "tc-1", input = "{\"command\":\"ls\"}" }, ts),
+ BuildEventLine("tool.execution_complete",
+ new { toolCallId = "tc-1", success = true, result = new { content = "file1.txt\nfile2.txt" } }, ts.AddSeconds(2)));
+
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+
+ Assert.Single(history); // tool start + complete are merged into one message
+ Assert.Equal(ChatMessageType.ToolCall, history[0].MessageType);
+ Assert.Equal("bash", history[0].ToolName);
+ Assert.Equal("tc-1", history[0].ToolCallId);
+ Assert.True(history[0].IsComplete);
+ Assert.True(history[0].IsSuccess);
+ Assert.Contains("file1.txt", history[0].Content!);
+ }
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_SkipsReportIntentTool()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+
+ CreateEventsFile(sessionId,
+ BuildEventLine("tool.execution_start",
+ new { toolName = "report_intent", toolCallId = "tc-2" }),
+ BuildEventLine("tool.execution_start",
+ new { toolName = "bash", toolCallId = "tc-3" }));
+
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+
+ Assert.Single(history);
+ Assert.Equal("bash", history[0].ToolName);
+ }
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_PreservesTimestamps()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+ var ts1 = new DateTimeOffset(2025, 6, 15, 10, 30, 0, TimeSpan.Zero);
+ var ts2 = new DateTimeOffset(2025, 6, 15, 10, 31, 0, TimeSpan.Zero);
+
+ CreateEventsFile(sessionId,
+ BuildEventLine("user.message", new { content = "First" }, ts1),
+ BuildEventLine("assistant.message", new { content = "Second" }, ts2));
+
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+
+ Assert.Equal(2, history.Count);
+ Assert.Equal(ts1, history[0].Timestamp);
+ Assert.Equal(ts2, history[1].Timestamp);
+ }
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_ReturnsEmptyForMissingFile()
+ {
+ var svc = CreateService();
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, Guid.NewGuid().ToString());
+ Assert.Empty(history);
+ }
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_ReturnsEmptyForEmptyFile()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+ CreateEventsFile(sessionId); // no lines
+
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+ Assert.Empty(history);
+ }
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_HandlesBlankLinesGracefully()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+
+ CreateEventsFile(sessionId,
+ "",
+ BuildEventLine("user.message", new { content = "test" }),
+ " ",
+ BuildEventLine("assistant.message", new { content = "response" }),
+ "");
+
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+ Assert.Equal(2, history.Count);
+ }
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_SkipsEventsWithoutTypeOrData()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+
+ CreateEventsFile(sessionId,
+ JsonSerializer.Serialize(new { data = new { content = "no type" } }),
+ JsonSerializer.Serialize(new { type = "user.message" }),
+ BuildEventLine("user.message", new { content = "valid" }));
+
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+ Assert.Single(history);
+ Assert.Equal("valid", history[0].Content);
+ }
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_MultipleConversationTurns()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+ var baseTs = DateTimeOffset.UtcNow.AddMinutes(-10);
+
+ CreateEventsFile(sessionId,
+ BuildEventLine("user.message", new { content = "What is 2+2?" }, baseTs),
+ BuildEventLine("assistant.message", new { content = "4" }, baseTs.AddSeconds(1)),
+ BuildEventLine("user.message", new { content = "And 3+3?" }, baseTs.AddSeconds(5)),
+ BuildEventLine("tool.execution_start", new { toolName = "calculator", toolCallId = "tc-calc" }, baseTs.AddSeconds(6)),
+ BuildEventLine("tool.execution_complete", new { toolCallId = "tc-calc", success = true, result = new { content = "6" } }, baseTs.AddSeconds(7)),
+ BuildEventLine("assistant.message", new { content = "The answer is 6" }, baseTs.AddSeconds(8)));
+
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+
+ // user(1) + assistant(1) + user(1) + tool(start+complete=1) + assistant(1) = 5
+ Assert.Equal(5, history.Count);
+ Assert.Equal("What is 2+2?", history[0].Content);
+ Assert.Equal("4", history[1].Content);
+ Assert.Equal("And 3+3?", history[2].Content);
+ Assert.Equal(ChatMessageType.ToolCall, history[3].MessageType);
+ Assert.Equal("The answer is 6", history[4].Content);
+ }
+
+ [Fact]
+ public async Task LoadHistoryFromDisk_ToolWithDetailedContent()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+
+ CreateEventsFile(sessionId,
+ BuildEventLine("tool.execution_start",
+ new { toolName = "grep", toolCallId = "tc-grep" }),
+ BuildEventLine("tool.execution_complete",
+ new { toolCallId = "tc-grep", success = true,
+ result = new { detailedContent = "Detailed grep output", content = "Short output" } }));
+
+ var history = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+ Assert.Single(history);
+ Assert.Equal("Detailed grep output", history[0].Content);
+ }
+
+ #endregion
+
+ #region 2. bestResponse multi-round accumulation logic
+ // NOTE: These tests verify the bestResponse accumulation *pattern* (longest-content-wins)
+ // in isolation. They duplicate the LINQ filter from RecoverFromPrematureIdleIfNeededAsync
+ // rather than calling the production method directly. If the production filter changes
+ // (e.g., > becomes >=), these tests won't detect the divergence.
+ // The §1, §7, and §8 tests call real production code. A future improvement would be to
+ // extract the accumulation/filtering logic into a testable internal static helper and
+ // test that instead.
+
+ [Fact]
+ public void BestResponseAccumulation_LongestContentWins()
+ {
+ // Simulate the bestResponse accumulation pattern from RecoverFromPrematureIdleIfNeededAsync
+ var dispatchTime = DateTimeOffset.UtcNow.AddMinutes(-5);
+ string? bestResponse = "short";
+
+ // Simulate round 1: session history has a longer response
+ var history = new List
+ {
+ ChatMessage.AssistantMessage("This is a much longer response from round 1"),
+ };
+ history[0].Timestamp = dispatchTime.AddSeconds(10);
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ if (latestContent != null && latestContent.Content!.Length > (bestResponse?.Length ?? 0))
+ {
+ bestResponse = latestContent.Content;
+ }
+
+ Assert.Equal("This is a much longer response from round 1", bestResponse);
+ }
+
+ [Fact]
+ public void BestResponseAccumulation_DoesNotDowngrade()
+ {
+ var dispatchTime = DateTimeOffset.UtcNow.AddMinutes(-5);
+ string? bestResponse = "This is the longer best response from round 1 that should be preserved";
+
+ // Round 2: session history has a shorter response
+ var history = new List
+ {
+ ChatMessage.AssistantMessage("Short"),
+ };
+ history[0].Timestamp = dispatchTime.AddSeconds(20);
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ if (latestContent != null && latestContent.Content!.Length > (bestResponse?.Length ?? 0))
+ {
+ bestResponse = latestContent.Content;
+ }
+
+ // bestResponse should NOT have been downgraded
+ Assert.Equal("This is the longer best response from round 1 that should be preserved", bestResponse);
+ }
+
+ [Fact]
+ public void BestResponseAccumulation_NullInitialResponse_UpgradesToAnyContent()
+ {
+ var dispatchTime = DateTimeOffset.UtcNow.AddMinutes(-5);
+ string? bestResponse = null; // initialResponse was null (empty TCS result)
+
+ var history = new List
+ {
+ ChatMessage.AssistantMessage("Recovery found content"),
+ };
+ history[0].Timestamp = dispatchTime.AddSeconds(5);
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ if (latestContent != null && latestContent.Content!.Length > (bestResponse?.Length ?? 0))
+ {
+ bestResponse = latestContent.Content;
+ }
+
+ Assert.Equal("Recovery found content", bestResponse);
+ }
+
+ [Fact]
+ public void BestResponseAccumulation_MultipleRoundsProgressivelyLonger()
+ {
+ var dispatchTime = DateTimeOffset.UtcNow.AddMinutes(-5);
+ string? bestResponse = "initial";
+
+ // Simulate 3 recovery rounds with progressively longer content
+ var roundResponses = new[]
+ {
+ "Round 1: medium-length recovery content from the first round",
+ "Round 2: this is a significantly longer recovery content that demonstrates progressive improvement across rounds",
+ "Round 3: final" // shorter — should NOT replace round 2
+ };
+
+ foreach (var roundContent in roundResponses)
+ {
+ var history = new List
+ {
+ ChatMessage.AssistantMessage(roundContent),
+ };
+ history[0].Timestamp = dispatchTime.AddSeconds(10);
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ if (latestContent != null && latestContent.Content!.Length > (bestResponse?.Length ?? 0))
+ {
+ bestResponse = latestContent.Content;
+ }
+ }
+
+ // Should have the longest (round 2), not the last (round 3)
+ Assert.Equal(roundResponses[1], bestResponse);
+ }
+
+ [Fact]
+ public void BestResponseAccumulation_IgnoresNonAssistantMessages()
+ {
+ var dispatchTime = DateTimeOffset.UtcNow.AddMinutes(-5);
+ string? bestResponse = null;
+
+ var history = new List
+ {
+ ChatMessage.UserMessage("User message — very long content that should be ignored by the recovery filter"),
+ ChatMessage.SystemMessage("System message — also long and should be ignored"),
+ ChatMessage.AssistantMessage("Short but valid"),
+ };
+ foreach (var m in history) m.Timestamp = dispatchTime.AddSeconds(5);
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ if (latestContent != null && latestContent.Content!.Length > (bestResponse?.Length ?? 0))
+ {
+ bestResponse = latestContent.Content;
+ }
+
+ Assert.Equal("Short but valid", bestResponse);
+ }
+
+ #endregion
+
+ #region 3. PrematureIdleSignal lifecycle
+ // NOTE: These tests exercise ManualResetEventSlim directly via the SessionState field.
+ // They verify the signaling primitive's behavior, not the production code that uses it.
+
+ [Fact]
+ public void PrematureIdleSignal_StartsUnset()
+ {
+ var info = new AgentSessionInfo { Name = "test", Model = "test-model" };
+ var state = CreateSessionState(info);
+ var signal = GetPrematureIdleSignal(state);
+
+ Assert.False(signal.IsSet);
+ }
+
+ [Fact]
+ public void PrematureIdleSignal_SetMakesItDetectable()
+ {
+ var info = new AgentSessionInfo { Name = "test", Model = "test-model" };
+ var state = CreateSessionState(info);
+ var signal = GetPrematureIdleSignal(state);
+
+ signal.Set();
+ Assert.True(signal.IsSet);
+ }
+
+ [Fact]
+ public void PrematureIdleSignal_ResetClearsSignal()
+ {
+ var info = new AgentSessionInfo { Name = "test", Model = "test-model" };
+ var state = CreateSessionState(info);
+ var signal = GetPrematureIdleSignal(state);
+
+ signal.Set();
+ Assert.True(signal.IsSet);
+
+ signal.Reset();
+ Assert.False(signal.IsSet);
+ }
+
+ [Fact]
+ public void PrematureIdleSignal_WaitReturnsTrueWhenSet()
+ {
+ var info = new AgentSessionInfo { Name = "test", Model = "test-model" };
+ var state = CreateSessionState(info);
+ var signal = GetPrematureIdleSignal(state);
+
+ signal.Set();
+ bool result = signal.Wait(100); // 100ms timeout
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void PrematureIdleSignal_WaitReturnsFalseWhenNotSetAndTimesOut()
+ {
+ var info = new AgentSessionInfo { Name = "test", Model = "test-model" };
+ var state = CreateSessionState(info);
+ var signal = GetPrematureIdleSignal(state);
+
+ bool result = signal.Wait(50); // short timeout — signal never set
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void PrematureIdleSignal_WaitUnblocksWhenSetFromAnotherThread()
+ {
+ var info = new AgentSessionInfo { Name = "test", Model = "test-model" };
+ var state = CreateSessionState(info);
+ var signal = GetPrematureIdleSignal(state);
+
+ bool wasSet = false;
+ var waitTask = Task.Run(() =>
+ {
+ wasSet = signal.Wait(5000); // generous timeout
+ });
+
+ // Set from main thread after a brief delay
+ Thread.Sleep(100);
+ signal.Set();
+
+ waitTask.Wait(3000);
+ Assert.True(wasSet, "Wait should have returned true after Set was called");
+ }
+
+ [Fact]
+ public void PrematureIdleSignal_DisposedSignalDoesNotThrowOnIsSetCheck()
+ {
+ var info = new AgentSessionInfo { Name = "test", Model = "test-model" };
+ var state = CreateSessionState(info);
+ var signal = GetPrematureIdleSignal(state);
+
+ signal.Dispose();
+
+ // The IsPrematureIdleSignalSet() local function in RecoverFromPrematureIdleIfNeededAsync
+ // catches ObjectDisposedException and returns false
+ bool result;
+ try
+ {
+ result = signal.IsSet;
+ }
+ catch (ObjectDisposedException)
+ {
+ result = false;
+ }
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void PrematureIdleSignal_DisposedSignalDoesNotThrowOnWait()
+ {
+ var info = new AgentSessionInfo { Name = "test", Model = "test-model" };
+ var state = CreateSessionState(info);
+ var signal = GetPrematureIdleSignal(state);
+
+ signal.Dispose();
+
+ // The WaitForPrematureIdleSignal() local function catches ObjectDisposedException
+ bool result;
+ try
+ {
+ result = signal.Wait(100);
+ }
+ catch (ObjectDisposedException)
+ {
+ result = false;
+ }
+ Assert.False(result);
+ }
+
+ #endregion
+
+ #region 4. OnSessionComplete event handler lifecycle
+
+ [Fact]
+ public async Task OnSessionComplete_SubscriptionDoesNotThrow()
+ {
+ var svc = CreateService();
+ await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo });
+
+ string? firedName = null;
+ string? firedSummary = null;
+ svc.OnSessionComplete += (name, summary) =>
+ {
+ firedName = name;
+ firedSummary = summary;
+ };
+
+ // Create a session and trigger a complete event
+ await svc.CreateSessionAsync("worker-1");
+ await svc.SendPromptAsync("worker-1", "hello");
+
+ // Demo mode doesn't reliably fire OnSessionComplete, so we verify
+ // that subscribing and sending a prompt doesn't throw.
+ // The TCS-based handler tests below (OnSessionComplete_TCSCompletesOnNameMatch etc.)
+ // verify the actual event dispatch pattern behaviorally.
+ Assert.NotNull(svc);
+ }
+
+ [Fact]
+ public void OnSessionComplete_TCSCompletesOnNameMatch()
+ {
+ // Simulate the pattern used in RecoverFromPrematureIdleIfNeededAsync:
+ // A TCS that completes when OnSessionComplete fires for the right worker name
+ var completionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ const string targetWorker = "worker-1";
+
+ void LocalHandler(string name, string _)
+ {
+ if (name == targetWorker)
+ completionTcs.TrySetResult(true);
+ }
+
+ // Simulate firing for matching name
+ LocalHandler("worker-1", "done");
+ Assert.True(completionTcs.Task.IsCompleted);
+ Assert.True(completionTcs.Task.Result);
+ }
+
+ [Fact]
+ public void OnSessionComplete_TCSDoesNotCompleteOnNameMismatch()
+ {
+ var completionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ const string targetWorker = "worker-1";
+
+ void LocalHandler(string name, string _)
+ {
+ if (name == targetWorker)
+ completionTcs.TrySetResult(true);
+ }
+
+ // Fire for a different worker
+ LocalHandler("worker-2", "done");
+ Assert.False(completionTcs.Task.IsCompleted);
+ }
+
+ [Fact]
+ public async Task OnSessionComplete_HandlerUnsubscribeStopsDelivery()
+ {
+ var svc = CreateService();
+ await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo });
+
+ int fireCount = 0;
+ void Handler(string name, string summary) => fireCount++;
+
+ svc.OnSessionComplete += Handler;
+ svc.OnSessionComplete -= Handler;
+
+ // Fire a session complete via the demo service
+ await svc.CreateSessionAsync("test-session");
+ await svc.SendPromptAsync("test-session", "hello");
+
+ // Handler was unsubscribed, so it shouldn't have incremented
+ Assert.Equal(0, fireCount);
+ }
+
+ [Fact]
+ public async Task OnSessionComplete_MultipleHandlersReceiveEvent()
+ {
+ var completionTcs1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var completionTcs2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ void Handler1(string name, string _)
+ {
+ if (name == "worker") completionTcs1.TrySetResult(true);
+ }
+ void Handler2(string name, string _)
+ {
+ if (name == "worker") completionTcs2.TrySetResult(true);
+ }
+
+ // Both handlers should fire
+ Handler1("worker", "done");
+ Handler2("worker", "done");
+
+ Assert.True(completionTcs1.Task.IsCompleted);
+ Assert.True(completionTcs2.Task.IsCompleted);
+ }
+
+ [Fact]
+ public void OnSessionComplete_CancellationRegistrationUnblocksTCS()
+ {
+ // Simulate the CTS timeout unblocking the TCS used in the recovery loop
+ var completionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var cts = new CancellationTokenSource();
+
+ using var reg = cts.Token.Register(() => completionTcs.TrySetResult(false));
+
+ // Cancel — should unblock with false
+ cts.Cancel();
+
+ Assert.True(completionTcs.Task.IsCompleted);
+ Assert.False(completionTcs.Task.Result);
+ }
+
+ #endregion
+
+ #region 5. OCE handling — bestResponse preservation
+ // NOTE: These tests re-implement the OCE catch-block pattern from
+ // RecoverFromPrematureIdleIfNeededAsync in isolation. See §2 note.
+
+ [Fact]
+ public void OCE_PreservesBestResponseOnCancellation()
+ {
+ // Simulate the OCE catch block in RecoverFromPrematureIdleIfNeededAsync
+ string? bestResponse = "Long accumulated recovery content from multiple rounds";
+ string? initialResponse = "short";
+
+ // The recovery loop catches OCE and returns bestResponse ?? initialResponse
+ string? result;
+ try
+ {
+ throw new OperationCanceledException("Recovery timeout");
+ }
+ catch (OperationCanceledException)
+ {
+ result = bestResponse ?? initialResponse;
+ }
+
+ Assert.Equal("Long accumulated recovery content from multiple rounds", result);
+ }
+
+ [Fact]
+ public void OCE_FallsBackToInitialResponseWhenBestResponseIsNull()
+ {
+ string? bestResponse = null;
+ string? initialResponse = "initial truncated content";
+
+ string? result;
+ try
+ {
+ throw new OperationCanceledException("Recovery timeout");
+ }
+ catch (OperationCanceledException)
+ {
+ result = bestResponse ?? initialResponse;
+ }
+
+ Assert.Equal("initial truncated content", result);
+ }
+
+ [Fact]
+ public async Task OCE_LinkedCTSPreservesAccumulatedContent()
+ {
+ // Simulate the linked CTS pattern from the recovery method
+ using var outerCts = new CancellationTokenSource();
+ using var recoveryCts = CancellationTokenSource.CreateLinkedTokenSource(outerCts.Token);
+ recoveryCts.CancelAfter(100); // short timeout
+
+ string? bestResponse = "Accumulated during recovery";
+
+ try
+ {
+ await Task.Delay(5000, recoveryCts.Token); // will be cancelled
+ bestResponse = "This should never be reached";
+ }
+ catch (OperationCanceledException)
+ {
+ // Preserve bestResponse — don't set it to null or re-throw
+ }
+
+ Assert.Equal("Accumulated during recovery", bestResponse);
+ }
+
+ [Fact]
+ public void OCE_OuterCancellationAlsoPreservesBestResponse()
+ {
+ // Simulate user abort (outer cancellation) during recovery
+ string? bestResponse = "Partial recovery before user abort";
+ string? initialResponse = "truncated";
+
+ string? result;
+ try
+ {
+ throw new OperationCanceledException("User aborted");
+ }
+ catch (OperationCanceledException)
+ {
+ result = bestResponse ?? initialResponse;
+ }
+
+ Assert.Equal("Partial recovery before user abort", result);
+ }
+
+ #endregion
+
+ #region 6. dispatchTime filtering correctness
+ // NOTE: These tests duplicate the dispatchTime LINQ filter from production code.
+ // See §2 note about the trade-off. §6.10 (DispatchTimeFilter_EndToEnd_DiskFallback)
+ // calls real production code (LoadHistoryFromDiskAsync) for end-to-end coverage.
+
+ [Fact]
+ public void DispatchTimeFilter_ExcludesMessagesBeforeDispatch()
+ {
+ var dispatchTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
+
+ var history = new List
+ {
+ CreateAssistantWithTimestamp("Old message from prior conversation", dispatchTime.AddMinutes(-30)),
+ CreateAssistantWithTimestamp("Stale response", dispatchTime.AddSeconds(-1)),
+ CreateAssistantWithTimestamp("Current response after dispatch", dispatchTime.AddSeconds(5)),
+ };
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ Assert.NotNull(latestContent);
+ Assert.Equal("Current response after dispatch", latestContent!.Content);
+ }
+
+ [Fact]
+ public void DispatchTimeFilter_IncludesMessageExactlyAtDispatchTime()
+ {
+ var dispatchTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
+
+ var history = new List
+ {
+ CreateAssistantWithTimestamp("Response at exact dispatch time", dispatchTime),
+ };
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ Assert.NotNull(latestContent);
+ Assert.Equal("Response at exact dispatch time", latestContent!.Content);
+ }
+
+ [Fact]
+ public void DispatchTimeFilter_ReturnsNullWhenNoMatchingMessages()
+ {
+ var dispatchTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
+
+ var history = new List
+ {
+ CreateAssistantWithTimestamp("Too old", dispatchTime.AddMinutes(-10)),
+ };
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ Assert.Null(latestContent);
+ }
+
+ [Fact]
+ public void DispatchTimeFilter_SelectsLastMatchingMessage()
+ {
+ var dispatchTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
+
+ var history = new List
+ {
+ CreateAssistantWithTimestamp("First valid", dispatchTime.AddSeconds(1)),
+ CreateAssistantWithTimestamp("Second valid — should be selected (last)", dispatchTime.AddSeconds(10)),
+ };
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ Assert.NotNull(latestContent);
+ Assert.Equal("Second valid — should be selected (last)", latestContent!.Content);
+ }
+
+ [Fact]
+ public void DispatchTimeFilter_IgnoresWhitespaceOnlyContent()
+ {
+ var dispatchTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
+
+ var history = new List
+ {
+ CreateAssistantWithTimestamp(" \t\n ", dispatchTime.AddSeconds(5)),
+ CreateAssistantWithTimestamp("Real content", dispatchTime.AddSeconds(10)),
+ };
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ Assert.Equal("Real content", latestContent!.Content);
+ }
+
+ [Fact]
+ public void DispatchTimeFilter_IgnoresToolCallMessages()
+ {
+ var dispatchTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
+
+ var toolMsg = ChatMessage.ToolCallMessage("bash", "tc-1");
+ toolMsg.Content = "Very long tool output that would win the length comparison";
+ toolMsg.Timestamp = dispatchTime.AddSeconds(5);
+
+ var assistantMsg = CreateAssistantWithTimestamp("Short valid", dispatchTime.AddSeconds(10));
+
+ var history = new List { toolMsg, assistantMsg };
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ Assert.Equal("Short valid", latestContent!.Content);
+ }
+
+ [Fact]
+ public void DispatchTimeFilter_IgnoresUserMessages()
+ {
+ var dispatchTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
+
+ var userMsg = ChatMessage.UserMessage("Long user message content");
+ userMsg.Timestamp = dispatchTime.AddSeconds(5);
+
+ var assistantMsg = CreateAssistantWithTimestamp("Assistant reply", dispatchTime.AddSeconds(10));
+
+ var history = new List { userMsg, assistantMsg };
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ Assert.Equal("Assistant reply", latestContent!.Content);
+ }
+
+ [Fact]
+ public async Task DispatchTimeFilter_WorksWithEventsFromDisk()
+ {
+ // End-to-end: create events.jsonl with timestamps, load via LoadHistoryFromDiskAsync,
+ // then apply the same dispatchTime filter used in production
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+ var dispatchTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
+
+ CreateEventsFile(sessionId,
+ BuildEventLine("user.message", new { content = "Old prompt" }, dispatchTime.AddMinutes(-10)),
+ BuildEventLine("assistant.message", new { content = "Old response — before dispatch" }, dispatchTime.AddMinutes(-9)),
+ BuildEventLine("user.message", new { content = "New prompt" }, dispatchTime.AddSeconds(1)),
+ BuildEventLine("assistant.message", new { content = "New response — after dispatch" }, dispatchTime.AddSeconds(5)));
+
+ var diskHistory = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+
+ var latestContent = diskHistory
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ Assert.NotNull(latestContent);
+ Assert.Equal("New response — after dispatch", latestContent!.Content);
+ }
+
+ [Fact]
+ public async Task DispatchTimeFilter_DiskFallbackWithMultipleAssistantMessages()
+ {
+ // Simulate the disk fallback path in RecoverFromPrematureIdleIfNeededAsync:
+ // When History didn't have better content, load from events.jsonl and filter
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+ var dispatchTime = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
+
+ // Simulate: agent produced truncated output first, then longer output after recovery
+ CreateEventsFile(sessionId,
+ BuildEventLine("user.message", new { content = "Analyze this code" }, dispatchTime.AddSeconds(1)),
+ BuildEventLine("assistant.message", new { content = "I'll analyze..." }, dispatchTime.AddSeconds(3)),
+ BuildEventLine("tool.execution_start", new { toolName = "bash", toolCallId = "tc-1" }, dispatchTime.AddSeconds(5)),
+ BuildEventLine("tool.execution_complete", new { toolCallId = "tc-1", success = true, result = new { content = "file list" } }, dispatchTime.AddSeconds(7)),
+ BuildEventLine("assistant.message", new { content = "After analyzing the code, here is my comprehensive review with detailed findings across all files..." }, dispatchTime.AddSeconds(15)));
+
+ var diskHistory = await InvokeLoadHistoryFromDiskAsync(svc, sessionId);
+
+ // The lastOrDefault with dispatchTime filter should get the last (longest) assistant message
+ var lastDisk = diskHistory
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ Assert.NotNull(lastDisk);
+ Assert.StartsWith("After analyzing", lastDisk!.Content);
+
+ // Verify the disk fallback would upgrade bestResponse (production pattern)
+ string? bestResponse = "I'll analyze..."; // truncated initial
+ if (lastDisk.Content!.Length > (bestResponse?.Length ?? 0))
+ {
+ bestResponse = lastDisk.Content;
+ }
+ Assert.StartsWith("After analyzing", bestResponse);
+ }
+
+ #endregion
+
+ #region 7. GetEventsFileMtime behavioral tests
+
+ [Fact]
+ public void GetEventsFileMtime_ReturnsNullForMissingSession()
+ {
+ var svc = CreateService();
+ var method = typeof(CopilotService).GetMethod("GetEventsFileMtime",
+ BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+ var result = method.Invoke(svc, new object?[] { Guid.NewGuid().ToString() });
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetEventsFileMtime_ReturnsNullForNullSessionId()
+ {
+ var svc = CreateService();
+ var method = typeof(CopilotService).GetMethod("GetEventsFileMtime",
+ BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+ var result = method.Invoke(svc, new object?[] { null });
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetEventsFileMtime_ReturnsTimeForExistingFile()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+ var eventsFile = CreateEventsFile(sessionId,
+ BuildEventLine("user.message", new { content = "test" }));
+
+ var method = typeof(CopilotService).GetMethod("GetEventsFileMtime",
+ BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+ var result = (DateTime?)method.Invoke(svc, new object?[] { sessionId });
+ Assert.NotNull(result);
+
+ // The mtime should be very recent (within last few seconds)
+ var age = DateTime.UtcNow - result!.Value;
+ Assert.True(age.TotalSeconds < 30, $"File mtime should be recent, but age was {age.TotalSeconds}s");
+ }
+
+ [Fact]
+ public void GetEventsFileMtime_DetectsFileModification()
+ {
+ var svc = CreateService();
+ var sessionId = Guid.NewGuid().ToString();
+ CreateEventsFile(sessionId,
+ BuildEventLine("user.message", new { content = "initial" }));
+
+ var method = typeof(CopilotService).GetMethod("GetEventsFileMtime",
+ BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+ var mtime1 = (DateTime?)method.Invoke(svc, new object?[] { sessionId });
+ Assert.NotNull(mtime1);
+
+ // Wait briefly and modify the file
+ Thread.Sleep(50);
+ var eventsPath = Path.Combine(GetSessionStatePath(), sessionId, "events.jsonl");
+ File.AppendAllText(eventsPath, "\n" + BuildEventLine("assistant.message", new { content = "new" }));
+
+ var mtime2 = (DateTime?)method.Invoke(svc, new object?[] { sessionId });
+ Assert.NotNull(mtime2);
+ Assert.True(mtime2!.Value >= mtime1!.Value,
+ "Modified file should have same or later mtime");
+ }
+
+ #endregion
+
+ #region 8. Constants validation (behavioral — verifying actual values matter)
+
+ [Fact]
+ public void PrematureIdleRecoveryTimeout_IsLongEnoughForToolExecution()
+ {
+ // Workers with long tool runs (e.g., multi-minute builds) need the timeout
+ // to be generous. 300s = 5 minutes is the current value.
+ Assert.True(CopilotService.PrematureIdleRecoveryTimeoutMs >= 60_000,
+ "Recovery timeout must be >= 60s for tool-heavy workers");
+ Assert.True(CopilotService.PrematureIdleRecoveryTimeoutMs <= 600_000,
+ "Recovery timeout must be <= 600s to avoid blocking orchestration forever");
+ }
+
+ [Fact]
+ public void PrematureIdleEventsSettleMs_IsLessThanGracePeriod()
+ {
+ // The settle phase must be shorter than the total grace period.
+ // settle + observe = grace, so settle < grace.
+ Assert.True(CopilotService.PrematureIdleEventsSettleMs < CopilotService.PrematureIdleEventsGracePeriodMs,
+ "Settle period must be less than the total grace period");
+ Assert.True(CopilotService.PrematureIdleEventsSettleMs > 0,
+ "Settle period must be positive");
+ }
+
+ [Fact]
+ public void PrematureIdleEventsGracePeriodMs_ObservationWindowIsReasonable()
+ {
+ // The observation window (grace - settle) should be meaningful
+ int observationWindow = CopilotService.PrematureIdleEventsGracePeriodMs - CopilotService.PrematureIdleEventsSettleMs;
+ Assert.True(observationWindow >= 500,
+ $"Observation window ({observationWindow}ms) must be >= 500ms to detect genuine CLI writes");
+ Assert.True(observationWindow <= 5000,
+ $"Observation window ({observationWindow}ms) must be <= 5000ms to avoid excessive wait");
+ }
+
+ [Fact]
+ public void PrematureIdleEventsFileFreshnessSeconds_IsReasonableForDetection()
+ {
+ Assert.True(CopilotService.PrematureIdleEventsFileFreshnessSeconds >= 5,
+ "Freshness threshold must be >= 5s to avoid false positives from OS flushing");
+ Assert.True(CopilotService.PrematureIdleEventsFileFreshnessSeconds <= 60,
+ "Freshness threshold must be <= 60s to detect stale files promptly");
+ }
+
+ #endregion
+
+ #region 9. Recovery loop TCS pattern — end-to-end simulation
+ // NOTE: These tests simulate the TCS/CTS coordination pattern from
+ // RecoverFromPrematureIdleIfNeededAsync but don't invoke it directly. See §2 note.
+
+ [Fact]
+ public async Task RecoveryLoop_TCSCompletesOnSessionCompleteEvent()
+ {
+ // Simulate the recovery loop pattern:
+ // 1. Create TCS
+ // 2. Subscribe OnSessionComplete handler that completes TCS for matching worker
+ // 3. Fire event → TCS completes
+ // 4. Collect content
+ var completionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ const string workerName = "TestGroup-Worker-1";
+
+ void LocalHandler(string name, string _)
+ {
+ if (name == workerName)
+ completionTcs.TrySetResult(true);
+ }
+
+ // Simulate firing from a background thread (as CompleteResponse does)
+ _ = Task.Run(() =>
+ {
+ Thread.Sleep(50);
+ LocalHandler(workerName, "completed successfully");
+ });
+
+ var completed = await completionTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.True(completed, "TCS should complete when OnSessionComplete fires for matching worker");
+ }
+
+ [Fact]
+ public async Task RecoveryLoop_CTSTimeoutUnblocksTCSWithFalse()
+ {
+ var completionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var recoveryCts = new CancellationTokenSource(100); // Short timeout
+
+ await using var reg = recoveryCts.Token.Register(() => completionTcs.TrySetResult(false));
+
+ var completed = await completionTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ Assert.False(completed, "TCS should complete with false when CTS times out");
+ }
+
+ [Fact]
+ public async Task RecoveryLoop_AlreadyDoneSessionCompletesImmediately()
+ {
+ // Simulate the pattern: if worker already finished (IsProcessing=false),
+ // TCS is set immediately without waiting for the event
+ var completionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ bool isProcessing = false; // Worker already finished
+
+ if (!isProcessing)
+ completionTcs.TrySetResult(true);
+
+ Assert.True(completionTcs.Task.IsCompleted);
+ Assert.True(await completionTcs.Task);
+ }
+
+ [Fact]
+ public async Task RecoveryLoop_MultipleRoundsAccumulateContent()
+ {
+ // Simulate multiple recovery rounds with the full accumulation pattern
+ var dispatchTime = DateTimeOffset.UtcNow.AddMinutes(-5);
+ string? bestResponse = null;
+ int rounds = 0;
+ var maxRounds = 3;
+
+ using var cts = new CancellationTokenSource(5000);
+
+ while (!cts.Token.IsCancellationRequested && rounds < maxRounds)
+ {
+ rounds++;
+ var completionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ // Simulate immediate completion (worker already done)
+ completionTcs.TrySetResult(true);
+
+ var completed = await completionTcs.Task;
+ if (!completed) break;
+
+ // Simulate progressively longer content per round
+ var roundContent = new string('x', rounds * 100);
+ var history = new List
+ {
+ ChatMessage.AssistantMessage(roundContent),
+ };
+ history[0].Timestamp = dispatchTime.AddSeconds(rounds * 5);
+
+ var latestContent = history
+ .LastOrDefault(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)
+ && m.MessageType == ChatMessageType.Assistant
+ && m.Timestamp >= dispatchTime);
+
+ if (latestContent != null && latestContent.Content!.Length > (bestResponse?.Length ?? 0))
+ {
+ bestResponse = latestContent.Content;
+ }
+
+ // Simulate "worker is truly done" check on last round
+ if (rounds >= maxRounds) break;
+ }
+
+ Assert.Equal(maxRounds, rounds);
+ Assert.NotNull(bestResponse);
+ Assert.Equal(maxRounds * 100, bestResponse!.Length);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static ChatMessage CreateAssistantWithTimestamp(string content, DateTimeOffset timestamp)
+ {
+ var msg = ChatMessage.AssistantMessage(content);
+ msg.Timestamp = timestamp;
+ return msg;
+ }
+
+ #endregion
+}