diff --git a/.claude/skills/copilot-sdk-reference/SKILL.md b/.claude/skills/copilot-sdk-reference/SKILL.md index d8ad7a20ce..58df5c708c 100644 --- a/.claude/skills/copilot-sdk-reference/SKILL.md +++ b/.claude/skills/copilot-sdk-reference/SKILL.md @@ -1,7 +1,7 @@ --- name: copilot-sdk-reference description: > - Complete API reference for GitHub.Copilot.SDK v0.2.1. Consult before implementing + Complete API reference for GitHub.Copilot.SDK v0.2.2. Consult before implementing session lifecycle, orchestration, event handling, hooks, plan management, context compaction, tool monitoring, or any feature that interacts with the Copilot CLI server. Use when: (1) Adding new session management code, (2) Modifying event handlers, @@ -10,11 +10,11 @@ description: > (7) Updating the SDK NuGet package version. --- -# GitHub Copilot SDK v0.2.1 β€” Complete API Reference +# GitHub Copilot SDK v0.2.2 β€” Complete API Reference -**Package:** `GitHub.Copilot.SDK` v0.2.1 -**NuGet:** `` -**XML Docs:** `~/.nuget/packages/github.copilot.sdk/0.2.1/lib/net8.0/GitHub.Copilot.SDK.xml` +**Package:** `GitHub.Copilot.SDK` v0.2.2 +**NuGet:** `` +**XML Docs:** `~/.nuget/packages/github.copilot.sdk/0.2.2/lib/net8.0/GitHub.Copilot.SDK.xml` **Types:** 453 total > **Rule:** Before implementing custom session/event/orchestration code, check this reference. @@ -388,6 +388,7 @@ Three built-in agents (in `definitions/*.agent.yaml`): | Version | Key Additions | |---------|---------------| +| **0.2.2** | **Breaking:** `SessionIdleData.BackgroundTasks` removed (use `SessionBackgroundTasksChangedEvent` instead), `ModelApi.SwitchToAsync` added `ModelCapabilitiesOverride?` parameter, `CompactionApi` removed (use `HistoryApi.CompactAsync`). **New:** `ISessionFsHandler` (10 filesystem methods), `HistoryApi.TruncateAsync`, `ServerSessionsApi.ForkAsync`, `ModelCapabilitiesOverride`/`ModelCapabilitiesOverrideLimits`/`ModelCapabilitiesOverrideLimitsVision` (vision support), `EntryType` enum, `ResumeSessionConfig.EnableConfigDiscovery`/`ModelCapabilities`, `ElicitationCompletedData.Action`/`Content`, `AssistantMessageData.RequestId` | | **0.2.1** | `CommandDefinition`/`CommandHandler` (custom slash commands), `ElicitationHandler`/`ElicitationContext` (structured input callbacks), `ServerMcpApi`, `ServerSessionFsApi`, `CapabilitiesChangedEvent`, `SamplingRequestedEvent`, `SessionRemoteSteerableChangedEvent`, `SessionCustomAgentsUpdatedEvent`, `ISessionUiApi`, `InputOptions` | | **0.2.0** | Hooks (PreToolUse, PostToolUse, UserPromptSubmitted, SessionStart, SessionEnd, ErrorOccurred), Plan API, Fleet API, Agent API, Skills API, MCP API, Compaction API, Workspace API, Elicitation API, ReasoningEffort, InfiniteSessionConfig, CustomAgentConfig, SystemMessageConfig with SectionOverride | diff --git a/.claude/skills/processing-state-safety/SKILL.md b/.claude/skills/processing-state-safety/SKILL.md index 953a4e46a2..4473600b7d 100644 --- a/.claude/skills/processing-state-safety/SKILL.md +++ b/.claude/skills/processing-state-safety/SKILL.md @@ -431,8 +431,10 @@ Before adding or modifying watchdog, IsProcessing, or stuck-session detection co | Detect when agent turn ends | `AgentStop` hook (JS SDK only) | πŸ”΄ **Not in .NET SDK** | JS SDK can block and force continuation; .NET SDK has `HookStartEvent`/`HookEndEvent` but no stop-gate | | Handle errors | `SessionHooks.OnErrorOccurred` | πŸ”΄ **Not adopted** | Has retry count and user notification fields | | Session lifecycle | `SessionHooks.OnSessionStart` / `OnSessionEnd` | πŸ”΄ **Not adopted** | Supplementary telemetry only β€” cannot replace restart/reconnect cleanup logic | -| Context compaction | `session.Rpc.Compaction.CompactAsync()` | πŸ”΄ **Not adopted** | Manual compaction trigger | -| Auto-compaction | `SessionConfig.InfiniteSessions` | πŸ”΄ **Not adopted** | Background compaction with configurable thresholds | +| Context compaction | `session.Rpc.History.CompactAsync()` | πŸ”΄ **Not adopted** | Manual compaction trigger (v0.2.2: moved from `CompactionApi` to `HistoryApi`) | +| Auto-compaction | `SessionConfig.InfiniteSessions` | βœ… **Adopted** | Used in all session configs with `Enabled = true` | +| History truncation | `session.Rpc.History.TruncateAsync()` | πŸ”΄ **Not adopted** | New in v0.2.2 β€” truncate session history to a specific point | +| Session forking | `ServerSessionsApi.ForkAsync()` | πŸ”΄ **Not adopted** | New in v0.2.2 β€” create a copy of a session with independent history | ### What to Keep Custom (and Why) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 83a0b94a4f..e73e5c2d95 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,7 @@ ## SDK-First Development -Before implementing new session lifecycle, orchestration, watchdog, or event handling code, check the Copilot SDK API surface. The `copilot-sdk-reference` skill has the complete v0.2.1 API reference (453 types, 76 events, 6 hooks, 16 RPC APIs). The `processing-state-safety` and `multi-agent-orchestration` skills each contain SDK migration matrices for their domains. Prefer SDK APIs over custom implementations. When custom code is necessary, add a `// SDK-gap: ` comment explaining why. +Before implementing new session lifecycle, orchestration, watchdog, or event handling code, check the Copilot SDK API surface. The `copilot-sdk-reference` skill has the complete v0.2.2 API reference (453 types, 76 events, 6 hooks, 16 RPC APIs). The `processing-state-safety` and `multi-agent-orchestration` skills each contain SDK migration matrices for their domains. Prefer SDK APIs over custom implementations. When custom code is necessary, add a `// SDK-gap: ` comment explaining why. ### SDK Update Audit (run when bumping `GitHub.Copilot.SDK` version) @@ -362,7 +362,7 @@ When a user changes the model via the UI dropdown: - Use `Convert.ToInt32(value)` for conversion, not `value as int?` - `AssistantUsageData` also includes: `Model`, `Cost` (billing multiplier), `Duration` (ms), `TtftMs` (time to first token), `InterTokenLatencyMs`, `ReasoningEffort`, `Initiator` (e.g., "sub-agent", "mcp-sampling"), `CopilotUsage`, `ApiCallId`, `ProviderCallId`, `ParentToolCallId` - `QuotaSnapshots` is `Dictionary` with `JsonElement` values β€” the typed fields (`EntitlementRequests`, `UsedRequests`, `RemainingPercentage`, `Overage`, `OverageAllowedWithExhaustedQuota`, `ResetDate`) are defined on `Rpc.AccountGetQuotaResultQuotaSnapshotsValue` -- `SessionIdleData` includes `BackgroundTasks` (agents + shells) and `Aborted` (bool?, true when turn was cancelled via abort) +- `SessionIdleData` includes `Aborted` (bool?, true when turn was cancelled via abort). **Note (v0.2.2):** `BackgroundTasks` was removed from `SessionIdleData` β€” background task tracking is now exclusively via `SessionBackgroundTasksChangedEvent`. The idle handler reads tracked state from `DeferredBackgroundTaskFingerprint`/`DeferredBackgroundTasksFirstSeenAtTicks` (set by the background tasks changed handler). - `MessageOptions` has 3 properties: `Prompt`, `Attachments`, `Mode` β€” no `Model` or `ReasoningEffort` (those are session-level via `SwitchToAsync`) ### Blazor Input Performance diff --git a/PolyPilot.Gtk/PolyPilot.Gtk.csproj b/PolyPilot.Gtk/PolyPilot.Gtk.csproj index b0c16d070c..5953f6f0d8 100644 --- a/PolyPilot.Gtk/PolyPilot.Gtk.csproj +++ b/PolyPilot.Gtk/PolyPilot.Gtk.csproj @@ -55,7 +55,7 @@ - + diff --git a/PolyPilot.Tests/BackgroundTasksIdleTests.cs b/PolyPilot.Tests/BackgroundTasksIdleTests.cs index f6ac5e51f4..4d13fcc83e 100644 --- a/PolyPilot.Tests/BackgroundTasksIdleTests.cs +++ b/PolyPilot.Tests/BackgroundTasksIdleTests.cs @@ -1,4 +1,3 @@ -using GitHub.Copilot.SDK; using PolyPilot.Models; using PolyPilot.Services; @@ -8,108 +7,99 @@ namespace PolyPilot.Tests; /// Tests for HasActiveBackgroundTasks β€” the fix that prevents premature idle completion /// when the SDK reports active background tasks (sub-agents, shells) in SessionIdleEvent. /// See: session.idle with backgroundTasks means "foreground quiesced, background still running." +/// +/// SDK v0.2.2: SessionIdleDataBackgroundTasks removed. Background tasks are now tracked +/// via BackgroundTaskSnapshot and SessionBackgroundTasksChangedEvent. /// public class BackgroundTasksIdleTests { - private static SessionIdleEvent MakeIdle(SessionIdleDataBackgroundTasks? bt = null) + // Test DTOs for reflection-based GetBackgroundTaskSnapshot / GetBackgroundTaskFirstSeenTicks + private class FakeBackgroundTasks { - return new SessionIdleEvent + public FakeAgent[] Agents { get; set; } = Array.Empty(); + public FakeShell[] Shells { get; set; } = Array.Empty(); + } + private class FakeAgent + { + public string AgentId { get; set; } = ""; + public string AgentType { get; set; } = ""; + public string Description { get; set; } = ""; + } + private class FakeShell + { + public string ShellId { get; set; } = ""; + public string Description { get; set; } = ""; + } + + private static CopilotService.BackgroundTaskSnapshot MakeSnapshot(int agents = 0, int shells = 0) + { + var parts = new List(); + for (int i = 0; i < agents; i++) parts.Add($"agent:agent-{i}"); + for (int i = 0; i < shells; i++) parts.Add($"shell:shell-{i}"); + return new CopilotService.BackgroundTaskSnapshot(agents, shells, string.Join("|", parts), IsKnown: true); + } + + private static FakeBackgroundTasks MakeFakeBt(int agents = 0, int shells = 0) + { + return new FakeBackgroundTasks { - Data = new SessionIdleData { BackgroundTasks = bt } + Agents = Enumerable.Range(0, agents) + .Select(i => new FakeAgent { AgentId = $"agent-{i}", AgentType = "copilot", Description = "" }) + .ToArray(), + Shells = Enumerable.Range(0, shells) + .Select(i => new FakeShell { ShellId = $"shell-{i}", Description = "" }) + .ToArray() }; } [Fact] public void HasActiveBackgroundTasks_NullBackgroundTasks_ReturnsFalse() { - var idle = MakeIdle(bt: null); - Assert.False(CopilotService.HasActiveBackgroundTasks(idle)); + var snapshot = CopilotService.GetBackgroundTaskSnapshot(null); + Assert.False(CopilotService.HasActiveBackgroundTasks(snapshot)); } [Fact] public void HasActiveBackgroundTasks_EmptyBackgroundTasks_ReturnsFalse() { - var idle = MakeIdle(new SessionIdleDataBackgroundTasks - { - Agents = Array.Empty(), - Shells = Array.Empty() - }); - Assert.False(CopilotService.HasActiveBackgroundTasks(idle)); + var snapshot = CopilotService.GetBackgroundTaskSnapshot(MakeFakeBt()); + Assert.False(CopilotService.HasActiveBackgroundTasks(snapshot)); } [Fact] public void HasActiveBackgroundTasks_WithAgents_ReturnsTrue() { - var idle = MakeIdle(new SessionIdleDataBackgroundTasks - { - Agents = new[] - { - new SessionIdleDataBackgroundTasksAgentsItem - { - AgentId = "agent-42", - AgentType = "copilot", - Description = "PR reviewer" - } - }, - Shells = Array.Empty() - }); - Assert.True(CopilotService.HasActiveBackgroundTasks(idle)); + var snapshot = CopilotService.GetBackgroundTaskSnapshot(MakeFakeBt(agents: 1)); + Assert.True(CopilotService.HasActiveBackgroundTasks(snapshot)); } [Fact] public void HasActiveBackgroundTasks_WithShells_ReturnsTrue() { - var idle = MakeIdle(new SessionIdleDataBackgroundTasks - { - Agents = Array.Empty(), - Shells = new[] - { - new SessionIdleDataBackgroundTasksShellsItem - { - ShellId = "shell-1", - Description = "Running npm test" - } - } - }); - Assert.True(CopilotService.HasActiveBackgroundTasks(idle)); + var snapshot = CopilotService.GetBackgroundTaskSnapshot(MakeFakeBt(shells: 1)); + Assert.True(CopilotService.HasActiveBackgroundTasks(snapshot)); } [Fact] public void HasActiveBackgroundTasks_WithBothAgentsAndShells_ReturnsTrue() { - var idle = MakeIdle(new SessionIdleDataBackgroundTasks - { - Agents = new[] - { - new SessionIdleDataBackgroundTasksAgentsItem - { - AgentId = "a1", AgentType = "copilot", Description = "" - } - }, - Shells = new[] - { - new SessionIdleDataBackgroundTasksShellsItem - { - ShellId = "s1", Description = "" - } - } - }); - Assert.True(CopilotService.HasActiveBackgroundTasks(idle)); + var snapshot = CopilotService.GetBackgroundTaskSnapshot(MakeFakeBt(agents: 1, shells: 1)); + Assert.True(CopilotService.HasActiveBackgroundTasks(snapshot)); } [Fact] - public void HasActiveBackgroundTasks_DefaultIdle_ReturnsFalse() + public void HasActiveBackgroundTasks_DefaultEmpty_ReturnsFalse() { - // Default SessionIdleEvent β€” Data is auto-initialized but BackgroundTasks is null - var idle = new SessionIdleEvent { Data = new SessionIdleData() }; - Assert.False(CopilotService.HasActiveBackgroundTasks(idle)); + // Empty snapshot β€” no agents, no shells + var snapshot = new CopilotService.BackgroundTaskSnapshot(0, 0, string.Empty, IsKnown: true); + Assert.False(CopilotService.HasActiveBackgroundTasks(snapshot)); } [Fact] - public void HasActiveBackgroundTasks_DataNull_ReturnsFalse() + public void HasActiveBackgroundTasks_NullInput_ReturnsFalse() { - var idle = new SessionIdleEvent { Data = null! }; - Assert.False(CopilotService.HasActiveBackgroundTasks(idle)); + var snapshot = CopilotService.GetBackgroundTaskSnapshot(null); + Assert.False(CopilotService.HasActiveBackgroundTasks(snapshot)); } // --- IDLE-DEFER-REARM tests (issue #403) --- @@ -125,24 +115,14 @@ public void IdleDeferRearm_ShouldRearm_WhenBackgroundTasksActiveAndNotProcessing var info = new AgentSessionInfo { Name = "test", Model = "test-model" }; info.IsProcessing = false; // Cleared by watchdog - var idle = MakeIdle(new SessionIdleDataBackgroundTasks - { - Agents = new[] - { - new SessionIdleDataBackgroundTasksAgentsItem - { - AgentId = "agent-1", AgentType = "copilot", Description = "worker" - } - }, - Shells = Array.Empty() - }); + var snapshot = MakeSnapshot(agents: 1); // Verify preconditions Assert.False(info.IsProcessing); - Assert.True(CopilotService.HasActiveBackgroundTasks(idle)); + Assert.True(CopilotService.HasActiveBackgroundTasks(snapshot)); // The re-arm logic: if !IsProcessing && HasActiveBackgroundTasks && !WasUserAborted β†’ re-arm - bool shouldRearm = !info.IsProcessing && CopilotService.HasActiveBackgroundTasks(idle); + bool shouldRearm = !info.IsProcessing && CopilotService.HasActiveBackgroundTasks(snapshot); Assert.True(shouldRearm, "Should re-arm when background tasks active and not processing"); // Simulate re-arm @@ -163,9 +143,9 @@ public void IdleDeferRearm_ShouldNotRearm_WhenNoBackgroundTasks() var info = new AgentSessionInfo { Name = "test", Model = "test-model" }; info.IsProcessing = false; - var idle = MakeIdle(bt: null); + var snapshot = CopilotService.GetBackgroundTaskSnapshot(null); - bool shouldRearm = !info.IsProcessing && CopilotService.HasActiveBackgroundTasks(idle); + bool shouldRearm = !info.IsProcessing && CopilotService.HasActiveBackgroundTasks(snapshot); Assert.False(shouldRearm, "Should NOT re-arm when no background tasks"); } @@ -177,19 +157,9 @@ public void IdleDeferRearm_ShouldNotRearm_WhenAlreadyProcessing() var info = new AgentSessionInfo { Name = "test", Model = "test-model" }; info.IsProcessing = true; // Already processing - var idle = MakeIdle(new SessionIdleDataBackgroundTasks - { - Agents = new[] - { - new SessionIdleDataBackgroundTasksAgentsItem - { - AgentId = "a1", AgentType = "copilot", Description = "" - } - }, - Shells = Array.Empty() - }); - - bool shouldRearm = !info.IsProcessing && CopilotService.HasActiveBackgroundTasks(idle); + var snapshot = MakeSnapshot(agents: 1); + + bool shouldRearm = !info.IsProcessing && CopilotService.HasActiveBackgroundTasks(snapshot); Assert.False(shouldRearm, "Should NOT re-arm when already processing"); } @@ -202,20 +172,10 @@ public void IdleDeferRearm_ShouldNotRearm_WhenUserAborted() info.IsProcessing = false; bool wasUserAborted = true; - var idle = MakeIdle(new SessionIdleDataBackgroundTasks - { - Agents = new[] - { - new SessionIdleDataBackgroundTasksAgentsItem - { - AgentId = "a1", AgentType = "copilot", Description = "" - } - }, - Shells = Array.Empty() - }); + var snapshot = MakeSnapshot(agents: 1); // The full re-arm condition includes WasUserAborted check - bool shouldRearm = !info.IsProcessing && CopilotService.HasActiveBackgroundTasks(idle) && !wasUserAborted; + bool shouldRearm = !info.IsProcessing && CopilotService.HasActiveBackgroundTasks(snapshot) && !wasUserAborted; Assert.False(shouldRearm, "Should NOT re-arm when user aborted"); } @@ -277,14 +237,7 @@ public void ProactiveIdleDefer_Handler_ResolvesDeferredIdleViaHelperWhenTasksCle [Fact] public void GetBackgroundTaskFirstSeenTicks_PreservesExistingTimestampWhenFingerprintMatches() { - var bt = new SessionIdleDataBackgroundTasks - { - Agents = Array.Empty(), - Shells = new[] - { - new SessionIdleDataBackgroundTasksShellsItem { ShellId = "shell-1", Description = "" } - } - }; + var bt = MakeFakeBt(shells: 1); var snapshot = CopilotService.GetBackgroundTaskSnapshot(bt); var existingTicks = DateTime.UtcNow.AddMinutes(-5).Ticks; @@ -300,13 +253,10 @@ public void GetBackgroundTaskFirstSeenTicks_PreservesExistingTimestampWhenFinger [Fact] public void GetBackgroundTaskFirstSeenTicks_RefreshesWhenFingerprintChanges() { - var bt = new SessionIdleDataBackgroundTasks + var bt = new FakeBackgroundTasks { - Agents = Array.Empty(), - Shells = new[] - { - new SessionIdleDataBackgroundTasksShellsItem { ShellId = "shell-2", Description = "" } - } + Agents = Array.Empty(), + Shells = new[] { new FakeShell { ShellId = "shell-2", Description = "" } } }; var before = DateTime.UtcNow; @@ -348,7 +298,8 @@ public void SessionIdle_StalePayload_NotDeferredWhenBgTasksAlreadyConfirmedEmpty Assert.Contains("idlePayloadIsStale", handler); Assert.Contains("preIdleFingerprint == string.Empty", handler); Assert.Contains("preIdleTicks == 0", handler); - Assert.Contains("tracking.Snapshot.HasAny", handler); + // hasActiveTasks uses tracked fingerprint state (not null snapshot) + Assert.Contains("hasTrackedTasks", handler); // hasActiveTasks must be guarded by !idlePayloadIsStale Assert.Contains("!idlePayloadIsStale", handler); } diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index f9ce703ba3..b6b928d0d0 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/PolyPilot.Tests/ZombieSubagentExpiryTests.cs b/PolyPilot.Tests/ZombieSubagentExpiryTests.cs index d56106a3be..5e1a5c1d86 100644 --- a/PolyPilot.Tests/ZombieSubagentExpiryTests.cs +++ b/PolyPilot.Tests/ZombieSubagentExpiryTests.cs @@ -1,4 +1,3 @@ -using GitHub.Copilot.SDK; using PolyPilot.Services; namespace PolyPilot.Tests; @@ -12,50 +11,51 @@ namespace PolyPilot.Tests; /// SubagentDeferStartedAtTicks) and expires the background agent block after /// SubagentZombieTimeoutMinutes, allowing the session to complete normally. /// See: issue #509 (expose CancelBackgroundTaskAsync via SDK). +/// +/// SDK v0.2.2: SessionIdleDataBackgroundTasks removed. Background tasks are now tracked +/// via BackgroundTaskSnapshot and SessionBackgroundTasksChangedEvent. /// public class ZombieSubagentExpiryTests { private static long TicksAgo(double minutes) => DateTime.UtcNow.AddMinutes(-minutes).Ticks; - private static SessionIdleEvent MakeIdleWithAgents(int agentCount = 1) + // Test DTOs for reflection-based GetBackgroundTaskSnapshot + private class FakeBackgroundTasks { - return new SessionIdleEvent - { - Data = new SessionIdleData - { - BackgroundTasks = new SessionIdleDataBackgroundTasks - { - Agents = Enumerable.Range(0, agentCount) - .Select(i => new SessionIdleDataBackgroundTasksAgentsItem - { - AgentId = $"agent-{i}", - AgentType = "copilot", - Description = "" - }).ToArray(), - Shells = Array.Empty() - } - } - }; + public FakeAgent[] Agents { get; set; } = Array.Empty(); + public FakeShell[] Shells { get; set; } = Array.Empty(); + } + private class FakeAgent + { + public string AgentId { get; set; } = ""; + public string AgentType { get; set; } = ""; + public string Description { get; set; } = ""; + } + private class FakeShell + { + public string ShellId { get; set; } = ""; + public string Description { get; set; } = ""; + } + + private static CopilotService.BackgroundTaskSnapshot MakeSnapshot(int agents = 0, int shells = 0) + { + var parts = new List(); + for (int i = 0; i < agents; i++) parts.Add($"agent:agent-{i}"); + for (int i = 0; i < shells; i++) parts.Add($"shell:shell-{i}"); + return new CopilotService.BackgroundTaskSnapshot(agents, shells, string.Join("|", parts), IsKnown: true); } - private static SessionIdleEvent MakeIdleWithShells(int shellCount = 1) + private static FakeBackgroundTasks MakeFakeBt(int agents = 0, int shells = 0) { - return new SessionIdleEvent + return new FakeBackgroundTasks { - Data = new SessionIdleData - { - BackgroundTasks = new SessionIdleDataBackgroundTasks - { - Agents = Array.Empty(), - Shells = Enumerable.Range(0, shellCount) - .Select(i => new SessionIdleDataBackgroundTasksShellsItem - { - ShellId = $"shell-{i}", - Description = "" - }).ToArray() - } - } + Agents = Enumerable.Range(0, agents) + .Select(i => new FakeAgent { AgentId = $"agent-{i}", AgentType = "copilot", Description = "" }) + .ToArray(), + Shells = Enumerable.Range(0, shells) + .Select(i => new FakeShell { ShellId = $"shell-{i}", Description = "" }) + .ToArray() }; } @@ -65,15 +65,15 @@ private static SessionIdleEvent MakeIdleWithShells(int shellCount = 1) public void ZeroTicks_ActiveAgent_ReturnsTrue() { // 0 means "not set" β€” behaviour is unchanged: any agent means "still running". - var idle = MakeIdleWithAgents(); - Assert.True(CopilotService.HasActiveBackgroundTasks(idle, idleDeferStartedAtTicks: 0)); + var snapshot = MakeSnapshot(agents: 1); + Assert.True(CopilotService.HasActiveBackgroundTasks(snapshot, idleDeferStartedAtTicks: 0)); } [Fact] public void ZeroTicks_NoTasks_ReturnsFalse() { - var idle = new SessionIdleEvent { Data = new SessionIdleData() }; - Assert.False(CopilotService.HasActiveBackgroundTasks(idle, idleDeferStartedAtTicks: 0)); + var snapshot = MakeSnapshot(); + Assert.False(CopilotService.HasActiveBackgroundTasks(snapshot, idleDeferStartedAtTicks: 0)); } // --- Fresh IDLE-DEFER (started recently β€” well within timeout) --- @@ -81,16 +81,16 @@ public void ZeroTicks_NoTasks_ReturnsFalse() [Fact] public void FreshDeferStart_ActiveAgent_ReturnsTrue() { - var idle = MakeIdleWithAgents(); - Assert.True(CopilotService.HasActiveBackgroundTasks(idle, TicksAgo(1))); + var snapshot = MakeSnapshot(agents: 1); + Assert.True(CopilotService.HasActiveBackgroundTasks(snapshot, TicksAgo(1))); } [Fact] public void DeferStartJustBelowThreshold_ReturnsTrue() { - var idle = MakeIdleWithAgents(); + var snapshot = MakeSnapshot(agents: 1); Assert.True(CopilotService.HasActiveBackgroundTasks( - idle, TicksAgo(CopilotService.SubagentZombieTimeoutMinutes - 1))); + snapshot, TicksAgo(CopilotService.SubagentZombieTimeoutMinutes - 1))); } // --- Zombie threshold reached --- @@ -98,17 +98,17 @@ public void DeferStartJustBelowThreshold_ReturnsTrue() [Fact] public void ZombieThresholdExceeded_SingleAgent_ReturnsFalse() { - var idle = MakeIdleWithAgents(); + var snapshot = MakeSnapshot(agents: 1); Assert.False(CopilotService.HasActiveBackgroundTasks( - idle, TicksAgo(CopilotService.SubagentZombieTimeoutMinutes + 1))); + snapshot, TicksAgo(CopilotService.SubagentZombieTimeoutMinutes + 1))); } [Fact] public void ZombieThresholdExceeded_MultipleAgents_ReturnsFalse() { // All 8 agents reported β€” none complete β€” reproduces the real incident - var idle = MakeIdleWithAgents(agentCount: 8); - Assert.False(CopilotService.HasActiveBackgroundTasks(idle, TicksAgo(42))); + var snapshot = MakeSnapshot(agents: 8); + Assert.False(CopilotService.HasActiveBackgroundTasks(snapshot, TicksAgo(42))); } [Fact] @@ -117,9 +117,9 @@ public void ZombieThresholdExactlyMet_ReturnsFalse() // At exactly the threshold, the session is considered expired. // TicksAgo(20) produces ticks > 20min ago (test executes in microseconds, not minutes), // so elapsed will be just over 20min and the threshold check fires correctly. - var idle = MakeIdleWithAgents(); + var snapshot = MakeSnapshot(agents: 1); Assert.False(CopilotService.HasActiveBackgroundTasks( - idle, TicksAgo(CopilotService.SubagentZombieTimeoutMinutes))); + snapshot, TicksAgo(CopilotService.SubagentZombieTimeoutMinutes))); } // --- Shells get a longer zombie timeout than agents --- @@ -129,47 +129,25 @@ public void AfterAgentThreshold_MixedShellsStillKeepSessionActive() { // At 30 minutes, agents should have expired but shells should still keep the // session deferred. This protects legitimate long-running build/test shells. - var idle = new SessionIdleEvent - { - Data = new SessionIdleData - { - BackgroundTasks = new SessionIdleDataBackgroundTasks - { - Agents = new[] - { - new SessionIdleDataBackgroundTasksAgentsItem - { - AgentId = "zombie-agent", AgentType = "copilot", Description = "" - } - }, - Shells = new[] - { - new SessionIdleDataBackgroundTasksShellsItem - { - ShellId = "shell-1", Description = "npm test" - } - } - } - } - }; + var snapshot = MakeSnapshot(agents: 1, shells: 1); Assert.True(CopilotService.HasActiveBackgroundTasks( - idle, TicksAgo(CopilotService.SubagentZombieTimeoutMinutes + 10))); + snapshot, TicksAgo(CopilotService.SubagentZombieTimeoutMinutes + 10))); } [Fact] public void ZombieThresholdExceeded_ShellsOnly_ReturnsFalse() { // Shells alone should eventually expire too β€” just with a longer threshold than agents. - var idle = MakeIdleWithShells(); + var snapshot = MakeSnapshot(shells: 1); Assert.False(CopilotService.HasActiveBackgroundTasks( - idle, TicksAgo(CopilotService.ShellZombieTimeoutMinutes + 1))); + snapshot, TicksAgo(CopilotService.ShellZombieTimeoutMinutes + 1))); } [Fact] public void FreshDeferStart_ShellsOnly_ReturnsTrue() { - var idle = MakeIdleWithShells(); - Assert.True(CopilotService.HasActiveBackgroundTasks(idle, TicksAgo(1))); + var snapshot = MakeSnapshot(shells: 1); + Assert.True(CopilotService.HasActiveBackgroundTasks(snapshot, TicksAgo(1))); } [Fact] @@ -177,9 +155,9 @@ public void AfterAgentThreshold_ShellsOnly_StillReturnsTrue() { // Shells should survive past the 20-minute agent timeout so legitimate long-running // commands do not get truncated just because they're shell-backed instead of subagents. - var idle = MakeIdleWithShells(); + var snapshot = MakeSnapshot(shells: 1); Assert.True(CopilotService.HasActiveBackgroundTasks( - idle, TicksAgo(CopilotService.SubagentZombieTimeoutMinutes + 5))); + snapshot, TicksAgo(CopilotService.SubagentZombieTimeoutMinutes + 5))); } [Fact] @@ -188,55 +166,50 @@ public void SameShellFingerprint_PreservesOriginalAgeAcrossTurns() // The same orphaned shell IDs can be reported again on later prompts. Their zombie age // must continue from the first time we saw them β€” otherwise every new prompt resets the // timer and the session can stay "busy" forever. - var idle = MakeIdleWithShells(shellCount: 2); - var snapshot = CopilotService.GetBackgroundTaskSnapshot(idle.Data?.BackgroundTasks); + var bt = MakeFakeBt(shells: 2); + var snapshot = CopilotService.GetBackgroundTaskSnapshot(bt); var staleTicks = TicksAgo(CopilotService.ShellZombieTimeoutMinutes + 5); var preservedTicks = CopilotService.GetBackgroundTaskFirstSeenTicks( - idle.Data?.BackgroundTasks, + bt, snapshot.Fingerprint, staleTicks, DateTime.UtcNow); Assert.Equal(staleTicks, preservedTicks); - Assert.False(CopilotService.HasActiveBackgroundTasks(idle, preservedTicks)); + Assert.False(CopilotService.HasActiveBackgroundTasks(snapshot, preservedTicks)); } [Fact] public void DifferentShellFingerprint_RefreshesAgeForNewBackgroundWork() { - var newShells = new SessionIdleDataBackgroundTasks + var bt = new FakeBackgroundTasks { - Agents = Array.Empty(), + Agents = Array.Empty(), Shells = new[] { - new SessionIdleDataBackgroundTasksShellsItem - { - ShellId = "shell-new", - Description = "fresh build" - } + new FakeShell { ShellId = "shell-new", Description = "fresh build" } } }; var staleTicks = TicksAgo(CopilotService.ShellZombieTimeoutMinutes + 5); var before = DateTime.UtcNow; var refreshedTicks = CopilotService.GetBackgroundTaskFirstSeenTicks( - newShells, + bt, "shell:shell-old", staleTicks, before); Assert.Equal(before.Ticks, refreshedTicks); - Assert.True(CopilotService.HasActiveBackgroundTasks( - new SessionIdleEvent { Data = new SessionIdleData { BackgroundTasks = newShells } }, - refreshedTicks)); + var snapshot = CopilotService.GetBackgroundTaskSnapshot(bt); + Assert.True(CopilotService.HasActiveBackgroundTasks(snapshot, refreshedTicks)); } [Fact] public void CarryOverShellOnlyTasks_FromPriorTurn_ShouldNotBlockNewTurn() { - var idle = MakeIdleWithShells(shellCount: 2); - var snapshot = CopilotService.GetBackgroundTaskSnapshot(idle.Data?.BackgroundTasks); + var bt = MakeFakeBt(shells: 2); + var snapshot = CopilotService.GetBackgroundTaskSnapshot(bt); Assert.True(CopilotService.ShouldIgnoreCarryOverShellOnlyTasks( snapshot, @@ -247,41 +220,14 @@ public void CarryOverShellOnlyTasks_FromPriorTurn_ShouldNotBlockNewTurn() [Fact] public void CarryOverShellOnlyTasks_DoNotApplyToCurrentTurnOrActiveAgents() { - var shellsOnly = MakeIdleWithShells(shellCount: 1); - var shellSnapshot = CopilotService.GetBackgroundTaskSnapshot(shellsOnly.Data?.BackgroundTasks); + var shellSnapshot = CopilotService.GetBackgroundTaskSnapshot(MakeFakeBt(shells: 1)); Assert.False(CopilotService.ShouldIgnoreCarryOverShellOnlyTasks( shellSnapshot, TicksAgo(1), DateTime.UtcNow.AddMinutes(-3))); - var withAgents = new SessionIdleEvent - { - Data = new SessionIdleData - { - BackgroundTasks = new SessionIdleDataBackgroundTasks - { - Agents = new[] - { - new SessionIdleDataBackgroundTasksAgentsItem - { - AgentId = "agent-1", - AgentType = "copilot", - Description = "worker" - } - }, - Shells = new[] - { - new SessionIdleDataBackgroundTasksShellsItem - { - ShellId = "shell-1", - Description = "tail -f" - } - } - } - } - }; - var mixedSnapshot = CopilotService.GetBackgroundTaskSnapshot(withAgents.Data?.BackgroundTasks); + var mixedSnapshot = CopilotService.GetBackgroundTaskSnapshot(MakeFakeBt(agents: 1, shells: 1)); Assert.False(CopilotService.ShouldIgnoreCarryOverShellOnlyTasks( mixedSnapshot, TicksAgo(10), @@ -308,7 +254,7 @@ public void StaleDeferTimestamp_FromPriorTurn_NewTurnShouldNotExpireAgents() // After the fix, SubagentDeferStartedAtTicks is reset at turn boundaries, so the // ??= CompareExchange sets a fresh timestamp for the new turn, and zombie expiry // is based on the new turn's actual elapsed time. - var idle = MakeIdleWithAgents(); + var snapshot = MakeSnapshot(agents: 1); // Simulate: stale ticks from 25 minutes ago NOT cleared at turn boundary long staleTicks = TicksAgo(25); @@ -317,36 +263,36 @@ public void StaleDeferTimestamp_FromPriorTurn_NewTurnShouldNotExpireAgents() // The test ASSERTS false to document what the broken behavior looks like, // and to verify that HasActiveBackgroundTasks correctly respects the ticks value. // The real invariant is: callers MUST pass 0 (not stale ticks) for new turns. - Assert.False(CopilotService.HasActiveBackgroundTasks(idle, staleTicks), + Assert.False(CopilotService.HasActiveBackgroundTasks(snapshot, staleTicks), "HasActiveBackgroundTasks correctly expires based on elapsed ticks β€” " + "the caller is responsible for resetting SubagentDeferStartedAtTicks at turn boundaries."); // The safe path: passing fresh ticks (new turn, new timestamp) should NOT expire agents long freshTicks = TicksAgo(1); - Assert.True(CopilotService.HasActiveBackgroundTasks(idle, freshTicks), + Assert.True(CopilotService.HasActiveBackgroundTasks(snapshot, freshTicks), "With fresh ticks (new turn), agents should NOT be expired β€” confirms the fix works."); } - // --- Backward compatibility with existing BackgroundTasksIdleTests --- + // --- Backward compatibility --- [Fact] public void BackwardCompat_NullBackgroundTasks_ReturnsFalse() { - var idle = new SessionIdleEvent { Data = new SessionIdleData { BackgroundTasks = null } }; - Assert.False(CopilotService.HasActiveBackgroundTasks(idle)); + var snapshot = CopilotService.GetBackgroundTaskSnapshot(null); + Assert.False(CopilotService.HasActiveBackgroundTasks(snapshot)); } [Fact] public void BackwardCompat_WithAgents_ReturnsTrue() { - var idle = MakeIdleWithAgents(); - Assert.True(CopilotService.HasActiveBackgroundTasks(idle)); + var snapshot = MakeSnapshot(agents: 1); + Assert.True(CopilotService.HasActiveBackgroundTasks(snapshot)); } [Fact] public void BackwardCompat_WithShells_ReturnsTrue() { - var idle = MakeIdleWithShells(); - Assert.True(CopilotService.HasActiveBackgroundTasks(idle)); + var snapshot = MakeSnapshot(shells: 1); + Assert.True(CopilotService.HasActiveBackgroundTasks(snapshot)); } } diff --git a/PolyPilot/PolyPilot.csproj b/PolyPilot/PolyPilot.csproj index de433c0050..d6d17673fe 100644 --- a/PolyPilot/PolyPilot.csproj +++ b/PolyPilot/PolyPilot.csproj @@ -81,7 +81,7 @@ - + diff --git a/PolyPilot/Services/CopilotService.Events.cs b/PolyPilot/Services/CopilotService.Events.cs index fe9f3a6455..2d08eeabc6 100644 --- a/PolyPilot/Services/CopilotService.Events.cs +++ b/PolyPilot/Services/CopilotService.Events.cs @@ -959,12 +959,12 @@ void Invoke(Action action) // Cancel the TurnEndβ†’Idle fallback β€” normal SessionIdleEvent arrived CancelTurnEndFallback(state); - // Diagnostic: dump raw backgroundTasks payload to prove whether CLI populates it consistently + // Diagnostic: log tracked background task state (v0.2.2 removed BackgroundTasks + // from SessionIdleData β€” tracking is now via SessionBackgroundTasksChangedEvent) { - var bt = idle.Data?.BackgroundTasks; - var agentCount = bt?.Agents?.Length ?? -1; - var shellCount = bt?.Shells?.Length ?? -1; - Debug($"[IDLE-DIAG] '{sessionName}' session.idle payload: backgroundTasks={{agents={agentCount}, shells={shellCount}, null={bt == null}}}"); + var trackedFingerprint = state.DeferredBackgroundTaskFingerprint; + var trackedTicks = Interlocked.Read(ref state.DeferredBackgroundTasksFirstSeenAtTicks); + Debug($"[IDLE-DIAG] '{sessionName}' session.idle payload: tracked fingerprint={trackedFingerprint ?? "null"}, ticks={trackedTicks}"); } // KEY FIX: age background tasks by stable fingerprint (agent/shell IDs), not just @@ -989,20 +989,43 @@ void Invoke(Action action) var preIdleFingerprint = state.DeferredBackgroundTaskFingerprint; var preIdleTicks = Interlocked.Read(ref state.DeferredBackgroundTasksFirstSeenAtTicks); - var tracking = RefreshDeferredBackgroundTaskTracking(state, idle.Data?.BackgroundTasks); - var deferTicks = tracking.FirstSeenTicks; - - bool idlePayloadIsStale = preIdleFingerprint == string.Empty && preIdleTicks == 0 && tracking.Snapshot.HasAny; + // SDK v0.2.2: BackgroundTasks removed from SessionIdleData. Background task + // tracking is now entirely via SessionBackgroundTasksChangedEvent, which calls + // RefreshDeferredBackgroundTaskTracking with real data. Here we just READ the + // tracked state β€” no RefreshDeferredBackgroundTaskTracking call (passing null + // would reset the tracked fingerprint/ticks to empty, destroying the state). + var deferTicks = Interlocked.Read(ref state.DeferredBackgroundTasksFirstSeenAtTicks); + + // Fingerprint tells us if background tasks are active: + // null β†’ no backgroundTasksChanged has fired (initial state) + // string.Empty β†’ backgroundTasksChanged confirmed zero tasks + // non-empty β†’ active tasks with this fingerprint + var currentFingerprint = state.DeferredBackgroundTaskFingerprint; + var hasTrackedTasks = !string.IsNullOrEmpty(currentFingerprint) && deferTicks > 0; + + bool idlePayloadIsStale = preIdleFingerprint == string.Empty && preIdleTicks == 0 && hasTrackedTasks; if (idlePayloadIsStale) - Debug($"[IDLE-DIAG-STALE] '{sessionName}' session.idle backgroundTasks " + - $"({tracking.Snapshot.AgentCount} agents, {tracking.Snapshot.ShellCount} shells) are stale β€” " + - $"backgroundTasksChanged already confirmed empty, completing normally"); - - var hasActiveTasks = !idlePayloadIsStale && HasActiveBackgroundTasks(idle, deferTicks); - var onlyCarryOverShellsRemain = !idlePayloadIsStale && ShouldIgnoreCarryOverShellOnlyTasks( - tracking.Snapshot, - deferTicks, - state.Info.ProcessingStartedAt); + Debug($"[IDLE-DIAG-STALE] '{sessionName}' session.idle β€” tracked tasks present but " + + $"backgroundTasksChanged previously confirmed empty, completing normally"); + + var hasActiveTasks = !idlePayloadIsStale && hasTrackedTasks; + + // Zombie timeout: check if tracked tasks have been active longer than the timeout + if (hasActiveTasks && deferTicks > 0) + { + var elapsed = TimeSpan.FromTicks(DateTime.UtcNow.Ticks - deferTicks); + if (elapsed.TotalMinutes >= SubagentZombieTimeoutMinutes) + { + Debug($"[IDLE-DEFER] '{sessionName}' tracked background tasks exceeded zombie timeout " + + $"({elapsed.TotalMinutes:F1}min >= {SubagentZombieTimeoutMinutes}min) β€” allowing completion"); + hasActiveTasks = false; + } + } + + // Carry-over shell detection: if tasks predate this turn, allow completion + var onlyCarryOverShellsRemain = hasActiveTasks && deferTicks > 0 + && state.Info.ProcessingStartedAt.HasValue + && deferTicks < state.Info.ProcessingStartedAt.Value.ToUniversalTime().Ticks; if (onlyCarryOverShellsRemain) { hasActiveTasks = false; @@ -1011,14 +1034,12 @@ void Invoke(Action action) $"by {carryOverMinutes:F1}min β€” allowing completion"); } - // Log zombie expiry here where Debug() is available (HasActiveBackgroundTasks is static) - var zombieAgentCount = tracking.Snapshot.AgentCount; - var zombieShellCount = tracking.Snapshot.ShellCount; + // Log zombie expiry (fingerprint indicates tasks were present but timed out) if (!hasActiveTasks && !idlePayloadIsStale && !onlyCarryOverShellsRemain && deferTicks != 0 && - (zombieAgentCount > 0 || zombieShellCount > 0)) + !string.IsNullOrEmpty(currentFingerprint)) { var expiredMinutes = TimeSpan.FromTicks(DateTime.UtcNow.Ticks - deferTicks).TotalMinutes; - Debug($"[IDLE-DEFER-ZOMBIE] '{sessionName}' {zombieAgentCount} agent(s) + {zombieShellCount} shell(s) " + + Debug($"[IDLE-DEFER-ZOMBIE] '{sessionName}' background tasks " + $"expired after {expiredMinutes:F0}min " + $"(threshold={SubagentZombieTimeoutMinutes}min) β€” allowing session to complete"); } @@ -2591,10 +2612,9 @@ internal static bool IsMcpError(string? text) /// legitimate long-running shell work. /// internal static bool HasActiveBackgroundTasks( - SessionIdleEvent idle, + BackgroundTaskSnapshot snapshot, long idleDeferStartedAtTicks = 0) { - var snapshot = GetBackgroundTaskSnapshot(idle.Data?.BackgroundTasks); bool hasAgents = snapshot.AgentCount > 0; bool hasShells = snapshot.ShellCount > 0; diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index 3ceb11a193..696e90cdd7 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -3313,7 +3313,7 @@ public async Task ChangeModelAsync(string sessionName, string newModel, st // Use the SDK's Model.SwitchToAsync for a lightweight mid-session model switch. // This preserves the session, conversation history, and event handlers β€” no need // to dispose/recreate the session or rewire event callbacks. - await state.Session.Rpc.Model.SwitchToAsync(normalizedModel, reasoningEffort, cancellationToken); + await state.Session.Rpc.Model.SwitchToAsync(normalizedModel, reasoningEffort, null, cancellationToken); state.Info.Model = normalizedModel; state.Info.ReasoningEffort = reasoningEffort;