diff --git a/Tests/Cockpit.Benchmarks/SessionLoadingBenchmarks.cs b/Tests/Cockpit.Benchmarks/SessionLoadingBenchmarks.cs index c28725d7..e9e9ff74 100644 --- a/Tests/Cockpit.Benchmarks/SessionLoadingBenchmarks.cs +++ b/Tests/Cockpit.Benchmarks/SessionLoadingBenchmarks.cs @@ -101,7 +101,7 @@ public SessionListFeature Original() Title = metadata.Summary ?? $"Session {metadata.SessionId[..8]}", CreatedAt = metadata.StartTime.UtcDateTime, LastActivity = metadata.ModifiedTime.UtcDateTime, - Status = SessionStatusEnum.Idle, + AgentRunState = AgentRunStateEnum.Idle, Model = _defaultModel, ReasoningEffort = _defaultModel.DefaultReasoningEffort, Context = new() diff --git a/Tests/Cockpit.UnitTests/Features/Agents/AgentFeatureTests.cs b/Tests/Cockpit.UnitTests/Features/Agents/AgentFeatureTests.cs index 1a637828..a50558ce 100644 --- a/Tests/Cockpit.UnitTests/Features/Agents/AgentFeatureTests.cs +++ b/Tests/Cockpit.UnitTests/Features/Agents/AgentFeatureTests.cs @@ -29,7 +29,7 @@ public void Dispose() { Id = Guid.NewGuid().ToString(), Title = "Test", - Status = SessionStatusEnum.Idle, + AgentRunState = AgentRunStateEnum.Idle, CreatedAt = DateTime.UtcNow, LastActivity = DateTime.UtcNow, Model = testModel, diff --git a/Tests/Cockpit.UnitTests/Features/ElicitationRequests/ElicitationFeatureTests.cs b/Tests/Cockpit.UnitTests/Features/ElicitationRequests/ElicitationFeatureTests.cs index c207778e..37d1a456 100644 --- a/Tests/Cockpit.UnitTests/Features/ElicitationRequests/ElicitationFeatureTests.cs +++ b/Tests/Cockpit.UnitTests/Features/ElicitationRequests/ElicitationFeatureTests.cs @@ -2,6 +2,7 @@ using Cockpit.Features.ElicitationRequests; using Cockpit.Features.Permissions.Models; using Cockpit.Features.Sessions; +using Cockpit.Features.Sessions.Interactions; using Cockpit.Features.Sessions.Models; using Cockpit.Features.UserInputRequests; using GitHub.Copilot; @@ -39,7 +40,7 @@ sealed class TestSessionStateProvider : ISessionStateProvider CreatedAt = DateTime.UtcNow, LastActivity = DateTime.UtcNow, Model = testModel, - Status = SessionStatusEnum.Idle, + AgentRunState = AgentRunStateEnum.Idle, Context = new() { CurrentWorkingDirectory = "", @@ -130,10 +131,10 @@ public async Task HandleElicitationRequest_SetsSessionStatusToNeedsElicitation() } [Fact] - public async Task HandleElicitationRequest_RestoredToPreviousStatusOnResolve() + public async Task HandleElicitationRequest_RevealsRunStateOnResolve() { (ElicitationFeature feature, SessionModel session, _) = CreateFeature(); - session.Status = SessionStatusEnum.Running; + session.AgentRunState = AgentRunStateEnum.Running; (Task handleTask, ElicitationRequestModel model) = await StartHandleAsync( feature, BuildContext()); @@ -145,10 +146,10 @@ public async Task HandleElicitationRequest_RestoredToPreviousStatusOnResolve() } [Fact] - public async Task HandleElicitationRequest_RestoredToIdleWhenNoStatusHistory() + public async Task HandleElicitationRequest_RestoredToIdleRunState() { (ElicitationFeature feature, SessionModel session, _) = CreateFeature(); - // session starts Idle, no history pushed yet + // Session starts with an Idle lifecycle state. (Task handleTask, ElicitationRequestModel model) = await StartHandleAsync( feature, BuildContext()); @@ -167,12 +168,12 @@ public async Task HandleElicitationRequest_PendingRequestAddedAndRemovedFromSess (Task handleTask, ElicitationRequestModel model) = await StartHandleAsync( feature, BuildContext()); - session.PendingElicitationRequests.ContainsKey(model.Id).ShouldBeTrue(); + session.PendingInteractions.Elicitations.ContainsKey(model.Id).ShouldBeTrue(); feature.ResolveElicitationRequest(model.Id, null); await handleTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); - session.PendingElicitationRequests.ContainsKey(model.Id).ShouldBeFalse(); + session.PendingInteractions.Elicitations.ContainsKey(model.Id).ShouldBeFalse(); } [Fact] @@ -202,16 +203,16 @@ public async Task HandleElicitationRequest_OnElicitationRequestedFiredWithCorrec } [Fact] - public async Task HandleElicitationRequest_SecondConcurrentRequestDoesNotPushStatusHistoryAgain() + public async Task HandleElicitationRequest_ConcurrentRequestsPreserveRunState() { (ElicitationFeature feature, SessionModel session, _) = CreateFeature(); - session.Status = SessionStatusEnum.Running; + session.AgentRunState = AgentRunStateEnum.Running; (Task task1, ElicitationRequestModel model1) = await StartHandleAsync(feature, BuildContext(message: "First")); (Task task2, ElicitationRequestModel model2) = await StartHandleAsync(feature, BuildContext(message: "Second")); - // Both pending — only one entry pushed to history (for the Running → NeedsElicitation transition) - session.StatusHistory.Count.ShouldBe(1); + // Pending interactions do not overwrite the Running lifecycle state. + session.AgentRunState.ShouldBe(AgentRunStateEnum.Running); session.Status.ShouldBe(SessionStatusEnum.NeedsElicitation); feature.ResolveElicitationRequest(model1.Id, null); @@ -225,6 +226,7 @@ public async Task HandleElicitationRequest_SecondConcurrentRequestDoesNotPushSta // All resolved — restored to Running session.Status.ShouldBe(SessionStatusEnum.Running); + session.AgentRunState.ShouldBe(AgentRunStateEnum.Running); } // ── Priority resolution ─────────────────────────────────────────────────── @@ -232,11 +234,13 @@ public async Task HandleElicitationRequest_SecondConcurrentRequestDoesNotPushSta [Fact] public async Task OnElicitationResolve_NeedsPermissionPrioritisedOverElicitation() { - (ElicitationFeature feature, SessionModel session, _) = CreateFeature(); + (ElicitationFeature feature, SessionModel session, TestSessionStateProvider stateProvider) = CreateFeature(); // Simulate a pending permission request already on the session - session.PendingPermissionRequests["perm-1"] = new PermissionRequestModel + SessionInteractionCoordinator interactionCoordinator = new(stateProvider); + interactionCoordinator.AddPermission(sessionId, new PermissionRequestModel { + Id = "perm-1", SessionId = sessionId, FullCommand = "ls", Commands = ["ls"], @@ -245,7 +249,7 @@ public async Task OnElicitationResolve_NeedsPermissionPrioritisedOverElicitation CanApproveGlobally = true, CanApproveForSession = true, FullRequestJson = "{}" - }; + }); (Task handleTask, ElicitationRequestModel model) = await StartHandleAsync( feature, BuildContext()); @@ -260,17 +264,19 @@ public async Task OnElicitationResolve_NeedsPermissionPrioritisedOverElicitation [Fact] public async Task OnElicitationResolve_NeedsUserInputPrioritisedOverElicitation() { - (ElicitationFeature feature, SessionModel session, _) = CreateFeature(); + (ElicitationFeature feature, SessionModel session, TestSessionStateProvider stateProvider) = CreateFeature(); // Simulate a pending user-input request already on the session - session.PendingUserInputRequests["ui-1"] = new UserInputRequestModel + SessionInteractionCoordinator interactionCoordinator = new(stateProvider); + interactionCoordinator.AddUserInput(sessionId, new UserInputRequestModel { + Id = "ui-1", SessionId = sessionId, Question = "Continue?", Choices = [], AllowsTextInput = true, FullRequestJson = "{}" - }; + }); (Task handleTask, ElicitationRequestModel model) = await StartHandleAsync( feature, BuildContext()); diff --git a/Tests/Cockpit.UnitTests/Features/Models/ModelFeatureTests.cs b/Tests/Cockpit.UnitTests/Features/Models/ModelFeatureTests.cs index 696c67b4..2ab1acde 100644 --- a/Tests/Cockpit.UnitTests/Features/Models/ModelFeatureTests.cs +++ b/Tests/Cockpit.UnitTests/Features/Models/ModelFeatureTests.cs @@ -64,7 +64,7 @@ public StubByokFeature(params ByokModelConfig[] configs) { Id = Guid.NewGuid().ToString(), Title = "Test", - Status = SessionStatusEnum.Idle, + AgentRunState = AgentRunStateEnum.Idle, CreatedAt = DateTime.UtcNow, LastActivity = DateTime.UtcNow, Model = MakeModel("default"), diff --git a/Tests/Cockpit.UnitTests/Features/Permissions/PermissionFeatureTests.cs b/Tests/Cockpit.UnitTests/Features/Permissions/PermissionFeatureTests.cs index 7618be9d..5977b5ab 100644 --- a/Tests/Cockpit.UnitTests/Features/Permissions/PermissionFeatureTests.cs +++ b/Tests/Cockpit.UnitTests/Features/Permissions/PermissionFeatureTests.cs @@ -708,7 +708,7 @@ public async Task ConcurrentPermissionChecks_NoRaceConditions() public async Task ResolvePermissionRequest_SessionScope_DoesNotResetStatusToIdle_WhenMultipleRequestsHaveSameCommands() { // Regression test: approving request A with Session scope must not cause status to revert to Idle. - // Previously AutoResolveMatchingRequests included A itself, double-popping StatusHistory and landing on Idle. + // Previously AutoResolveMatchingRequests included A itself and resolved the same request twice. string testFile = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.json"); GlobalPermissionFeature globalFeature = new(NullLogger.Instance, testFile); TestSessionStateProvider stateProvider = new(); @@ -725,7 +725,7 @@ public async Task ResolvePermissionRequest_SessionScope_DoesNotResetStatusToIdle CreatedAt = DateTime.UtcNow, LastActivity = DateTime.UtcNow, Model = testModel, - Status = SessionStatusEnum.Running, + AgentRunState = AgentRunStateEnum.Running, Context = new() { CurrentWorkingDirectory = "", @@ -865,7 +865,7 @@ public async Task CancelPendingRequestsForSession_CancelsAllPending() CreatedAt = DateTime.UtcNow, LastActivity = DateTime.UtcNow, Model = testModel, - Status = SessionStatusEnum.Running, + AgentRunState = AgentRunStateEnum.Running, Context = new() { CurrentWorkingDirectory = "", @@ -960,7 +960,7 @@ public async Task ResolvePermissionRequest_GlobalApproval_BlockedByDenyList_Down CreatedAt = DateTime.UtcNow, LastActivity = DateTime.UtcNow, Model = testModel, - Status = SessionStatusEnum.Running, + AgentRunState = AgentRunStateEnum.Running, Context = new() { CurrentWorkingDirectory = "", @@ -1026,7 +1026,7 @@ public async Task ResolvePermissionRequest_GlobalApprovalBlockedByDenyList_DoesN CreatedAt = DateTime.UtcNow, LastActivity = DateTime.UtcNow, Model = testModel, - Status = SessionStatusEnum.Running, + AgentRunState = AgentRunStateEnum.Running, Context = new() { CurrentWorkingDirectory = "", diff --git a/Tests/Cockpit.UnitTests/Features/SessionEvents/Handlers/SessionIdleHandlerTests.cs b/Tests/Cockpit.UnitTests/Features/SessionEvents/Handlers/SessionIdleHandlerTests.cs index 970bab85..ffa66fd1 100644 --- a/Tests/Cockpit.UnitTests/Features/SessionEvents/Handlers/SessionIdleHandlerTests.cs +++ b/Tests/Cockpit.UnitTests/Features/SessionEvents/Handlers/SessionIdleHandlerTests.cs @@ -66,7 +66,7 @@ public void Handle_WithNoGroup_SetsIdle() { // Arrange SessionModel session = CreateSession(); - session.Status = SessionStatusEnum.Running; + session.AgentRunState = AgentRunStateEnum.Running; SessionEventProcessor processor = CreateProcessor(); // Act diff --git a/Tests/Cockpit.UnitTests/Features/SessionEvents/Handlers/SessionShutdownHandlerTests.cs b/Tests/Cockpit.UnitTests/Features/SessionEvents/Handlers/SessionShutdownHandlerTests.cs index 39414aef..cbe484b0 100644 --- a/Tests/Cockpit.UnitTests/Features/SessionEvents/Handlers/SessionShutdownHandlerTests.cs +++ b/Tests/Cockpit.UnitTests/Features/SessionEvents/Handlers/SessionShutdownHandlerTests.cs @@ -43,7 +43,7 @@ public void Handle_RoutineShutdown_DoesNotFinalizeActiveGroup() // Arrange — routine == auto-restart; the session continues, so the group must be preserved SessionModel session = CreateSession(); SessionEventProcessor processor = CreateProcessor(); - session.Status = SessionStatusEnum.Running; + session.AgentRunState = AgentRunStateEnum.Running; processor.Process(session, new ToolExecutionStartEvent { @@ -68,7 +68,7 @@ public void Handle_RoutineShutdown_WithNoGroup_DoesNotSetIdle() { // A routine shutdown with no active group must leave session status unchanged SessionModel session = CreateSession(); - session.Status = SessionStatusEnum.Running; + session.AgentRunState = AgentRunStateEnum.Running; SessionEventProcessor processor = CreateProcessor(); processor.Process(session, new SessionShutdownEvent @@ -112,7 +112,7 @@ public void Handle_ErrorShutdown_WithNoActiveGroup_SetsIdle() { // Even without an active working group, a non-routine shutdown must transition to Idle SessionModel session = CreateSession(); - session.Status = SessionStatusEnum.Running; + session.AgentRunState = AgentRunStateEnum.Running; SessionEventProcessor processor = CreateProcessor(); processor.Process(session, new SessionShutdownEvent diff --git a/Tests/Cockpit.UnitTests/Features/SessionEvents/SessionEventProcessorTests.cs b/Tests/Cockpit.UnitTests/Features/SessionEvents/SessionEventProcessorTests.cs index 7692d335..856e0b1a 100644 --- a/Tests/Cockpit.UnitTests/Features/SessionEvents/SessionEventProcessorTests.cs +++ b/Tests/Cockpit.UnitTests/Features/SessionEvents/SessionEventProcessorTests.cs @@ -192,7 +192,7 @@ public void Process_SessionIdle_WithPendingMessages_FinalizesAndKeepsRunning() // Arrange: enqueue mode — group should be finalized but session stays Running SessionModel session = CreateSession(); SessionEventProcessor processor = CreateProcessor(); - session.Status = SessionStatusEnum.Running; + session.AgentRunState = AgentRunStateEnum.Running; session.Messages.Add(new ChatMessageModel { Id = "user1", IsUser = true, Content = "Hello", EventJson = null }); session.Messages.Add(new ChatMessageModel { Id = "user2", IsUser = true, Content = "Queued", IsPending = true, EventJson = null }); session.ActiveWorkingGroup = new ActivityGroupModel diff --git a/Tests/Cockpit.UnitTests/Features/Sessions/SessionConversationStateTests.cs b/Tests/Cockpit.UnitTests/Features/Sessions/SessionConversationStateTests.cs new file mode 100644 index 00000000..aaf3d24a --- /dev/null +++ b/Tests/Cockpit.UnitTests/Features/Sessions/SessionConversationStateTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Immutable; +using Cockpit.Features.SessionEvents.Models; +using Cockpit.Features.Sessions.Models; +using GitHub.Copilot; +using Shouldly; + +namespace Cockpit.UnitTests.Features.Sessions; + +public sealed class SessionConversationStateTests +{ + static SessionModel CreateSession() => new() + { + Id = "conversation-session", + Title = "Conversation state", + CreatedAt = DateTime.UtcNow, + LastActivity = DateTime.UtcNow, + Model = new ModelInfo { Id = "test", Name = "Test Model" }, + Context = new() + { + CurrentWorkingDirectory = string.Empty, + WorkspacePath = null, + GitRoot = null, + Repository = null, + Branch = null + } + }; + + [Fact] + public void ReplacingMessages_PublishesSnapshotThroughConversationState() + { + SessionModel session = CreateSession(); + ChatMessageModel message = new() { Id = "message", Content = "Hello", EventJson = null }; + + session.Conversation.ReplaceMessages([message]); + + session.Conversation.Messages.ShouldBeSameAs(session.Messages); + session.Conversation.MessagesSnapshot.ShouldBe([message]); + session.MessagesSnapshot.ShouldBe([message]); + } + + [Fact] + public void PublishedSnapshot_RemainsStableUntilNextExplicitPublication() + { + SessionModel session = CreateSession(); + ChatMessageModel first = new() { Id = "first", Content = "First", EventJson = null }; + ChatMessageModel second = new() { Id = "second", Content = "Second", EventJson = null }; + session.Conversation.ReplaceMessages([first]); + ImmutableArray firstSnapshot = session.Conversation.MessagesSnapshot; + + session.Conversation.Messages.Add(second); + + firstSnapshot.ShouldBe([first]); + session.Conversation.MessagesSnapshot.ShouldBe([first]); + + session.Conversation.PublishMessagesSnapshot(); + + firstSnapshot.ShouldBe([first]); + session.Conversation.MessagesSnapshot.ShouldBe([first, second]); + } + + [Fact] + public void ReplacingMessages_DoesNotRetainCallersMutableCollection() + { + SessionModel session = CreateSession(); + ChatMessageModel message = new() { Id = "message", Content = "Hello", EventJson = null }; + List source = [message]; + + session.Conversation.ReplaceMessages(source); + source.Clear(); + + session.Conversation.Messages.ShouldBe([message]); + session.Conversation.MessagesSnapshot.ShouldBe([message]); + } + + [Fact] + public void ClearMessages_ReleasesRetainedListCapacity() + { + SessionModel session = CreateSession(); + for(int i = 0; i < 256; i++) + { + session.Conversation.Messages.Add(new ChatMessageModel + { + Id = $"message-{i}", + Content = "Message", + EventJson = null + }); + } + session.Conversation.PublishMessagesSnapshot(); + session.Conversation.Messages.Capacity.ShouldBeGreaterThan(0); + + session.Conversation.ClearMessages(); + + session.Conversation.Messages.ShouldBeEmpty(); + session.Conversation.Messages.Capacity.ShouldBe(0); + session.Conversation.MessagesSnapshot.ShouldBeEmpty(); + } + + [Fact] + public void CompatibilitySurface_ForwardsToSingleConversationState() + { + SessionModel session = CreateSession(); + ActivityGroupModel group = new(); + + session.ActiveWorkingGroup = group; + session.PendingMessageCount = 2; + session.IsCompacting = true; + session.AgentTurnCompleted = true; + session.HasQueuedImmediateMessage = true; + session.PendingTaskSummary = "Complete"; + + session.Conversation.ActiveWorkingGroup.ShouldBeSameAs(group); + session.Conversation.PendingMessageCount.ShouldBe(2); + session.Conversation.IsCompacting.ShouldBeTrue(); + session.Conversation.AgentTurnCompleted.ShouldBeTrue(); + session.Conversation.HasQueuedImmediateMessage.ShouldBeTrue(); + session.Conversation.PendingTaskSummary.ShouldBe("Complete"); + (session.SessionEventLock == session.Conversation.SyncRoot).ShouldBeTrue(); + } +} diff --git a/Tests/Cockpit.UnitTests/Features/Sessions/SessionInteractionCoordinatorTests.cs b/Tests/Cockpit.UnitTests/Features/Sessions/SessionInteractionCoordinatorTests.cs new file mode 100644 index 00000000..cb451b5f --- /dev/null +++ b/Tests/Cockpit.UnitTests/Features/Sessions/SessionInteractionCoordinatorTests.cs @@ -0,0 +1,176 @@ +using Cockpit.Features.ElicitationRequests; +using Cockpit.Features.Permissions.Models; +using Cockpit.Features.Sessions; +using Cockpit.Features.Sessions.Interactions; +using Cockpit.Features.Sessions.Models; +using Cockpit.Features.UserInputRequests; +using GitHub.Copilot; +using Shouldly; + +namespace Cockpit.UnitTests.Features.Sessions; + +public sealed class SessionInteractionCoordinatorTests +{ + const string sessionId = "interaction-session"; + static readonly ModelInfo testModel = new() { Id = "test", Name = "Test Model" }; + + sealed class TestSessionStateProvider : ISessionStateProvider + { + readonly List _sessions = []; + + public event Action? OnStateChanged; + public IReadOnlyList Sessions => _sessions; + public SessionModel? CurrentSession => _sessions.FirstOrDefault(); + public int NotificationCount { get; private set; } + + public void Add(SessionModel session) => _sessions.Add(session); + + public void NotifyStateChanged() + { + NotificationCount++; + OnStateChanged?.Invoke(); + } + } + + static SessionModel CreateSession() => new() + { + Id = sessionId, + Title = "Interaction coordinator", + CreatedAt = DateTime.UtcNow, + LastActivity = DateTime.UtcNow, + Model = testModel, + AgentRunState = AgentRunStateEnum.Running, + Context = new() + { + CurrentWorkingDirectory = string.Empty, + WorkspacePath = null, + GitRoot = null, + Repository = null, + Branch = null + } + }; + + static PermissionRequestModel CreatePermission(string id = "permission") => new() + { + Id = id, + SessionId = sessionId, + FullCommand = "command", + Commands = ["command"], + RequestTitle = "Allow command", + Intention = "test", + CanApproveGlobally = true, + CanApproveForSession = true, + FullRequestJson = "{}" + }; + + static UserInputRequestModel CreateUserInput() => new() + { + SessionId = sessionId, + Question = "Continue?", + Choices = [], + AllowsTextInput = true, + FullRequestJson = "{}" + }; + + static ElicitationRequestModel CreateElicitation() => new() + { + SessionId = sessionId, + Message = "Provide details", + Fields = [], + ElicitationSource = "test" + }; + + [Fact] + public void ConcurrentTypes_PreserveRunStateAndExistingDisplayOrder() + { + TestSessionStateProvider stateProvider = new(); + SessionModel session = CreateSession(); + stateProvider.Add(session); + SessionInteractionCoordinator coordinator = new(stateProvider); + PermissionRequestModel permission = CreatePermission(); + UserInputRequestModel userInput = CreateUserInput(); + ElicitationRequestModel elicitation = CreateElicitation(); + + coordinator.AddPermission(sessionId, permission); + coordinator.AddUserInput(sessionId, userInput); + coordinator.AddElicitation(sessionId, elicitation); + + session.AgentRunState.ShouldBe(AgentRunStateEnum.Running); + session.Status.ShouldBe(SessionStatusEnum.NeedsElicitation); + + coordinator.ResolveElicitation(sessionId, elicitation.Id); + session.Status.ShouldBe(SessionStatusEnum.NeedsPermission); + + coordinator.ResolvePermission(sessionId, permission.Id); + session.Status.ShouldBe(SessionStatusEnum.NeedsUserInput); + + coordinator.ResolveUserInput(sessionId, userInput.Id); + session.Status.ShouldBe(SessionStatusEnum.Running); + session.AgentRunState.ShouldBe(AgentRunStateEnum.Running); + stateProvider.NotificationCount.ShouldBe(6); + } + + [Fact] + public void DuplicateRequest_DoesNotChangeStatusOrNotifyAgain() + { + TestSessionStateProvider stateProvider = new(); + SessionModel session = CreateSession(); + stateProvider.Add(session); + SessionInteractionCoordinator coordinator = new(stateProvider); + PermissionRequestModel permission = CreatePermission(); + + coordinator.AddPermission(sessionId, permission); + coordinator.AddPermission(sessionId, permission); + + session.PendingInteractions.Permissions.Count.ShouldBe(1); + session.AgentRunState.ShouldBe(AgentRunStateEnum.Running); + session.Status.ShouldBe(SessionStatusEnum.NeedsPermission); + stateProvider.NotificationCount.ShouldBe(1); + } + + [Fact] + public void RunStateChangeWhileInteractionIsPending_IsRevealedAfterResolution() + { + TestSessionStateProvider stateProvider = new(); + SessionModel session = CreateSession(); + stateProvider.Add(session); + SessionInteractionCoordinator coordinator = new(stateProvider); + PermissionRequestModel permission = CreatePermission(); + + coordinator.AddPermission(sessionId, permission); + session.AgentRunState = AgentRunStateEnum.Error; + + session.DisplayStatus.ShouldBe(SessionStatusEnum.NeedsPermission); + + coordinator.ResolvePermission(sessionId, permission.Id); + + session.AgentRunState.ShouldBe(AgentRunStateEnum.Error); + session.DisplayStatus.ShouldBe(SessionStatusEnum.Error); + } + + [Fact] + public void ResolveAfterReconnectCleanup_DoesNotChangeRunStateOrNotify() + { + TestSessionStateProvider stateProvider = new(); + SessionModel session = CreateSession(); + stateProvider.Add(session); + SessionInteractionCoordinator coordinator = new(stateProvider); + PermissionRequestModel permission = CreatePermission(); + + coordinator.AddPermission(sessionId, permission); + + // Reconnect restores the lifecycle status and clears UI bookkeeping before + // cancellation completes the SDK-facing request. + session.AgentRunState = AgentRunStateEnum.Running; + coordinator.ClearBookkeeping( + sessionId, + PendingInteractionKinds.All); + + coordinator.ResolvePermission(sessionId, permission.Id); + + session.Status.ShouldBe(SessionStatusEnum.Running); + session.AgentRunState.ShouldBe(AgentRunStateEnum.Running); + session.PendingInteractions.Permissions.ShouldBeEmpty(); + stateProvider.NotificationCount.ShouldBe(1); + } +} diff --git a/Tests/Cockpit.UnitTests/Features/Sessions/SessionLifecycleStateTests.cs b/Tests/Cockpit.UnitTests/Features/Sessions/SessionLifecycleStateTests.cs new file mode 100644 index 00000000..fff11660 --- /dev/null +++ b/Tests/Cockpit.UnitTests/Features/Sessions/SessionLifecycleStateTests.cs @@ -0,0 +1,56 @@ +using Cockpit.Features.Sessions.Models; +using GitHub.Copilot; +using Shouldly; + +namespace Cockpit.UnitTests.Features.Sessions; + +public sealed class SessionLifecycleStateTests +{ + static SessionModel CreateSession() => new() + { + Id = "lifecycle-session", + Title = "Lifecycle state", + CreatedAt = DateTime.UtcNow, + LastActivity = DateTime.UtcNow, + Model = new ModelInfo { Id = "test", Name = "Test Model" }, + Context = new() + { + CurrentWorkingDirectory = string.Empty, + WorkspacePath = null, + GitRoot = null, + Repository = null, + Branch = null + } + }; + + [Fact] + public void CompatibilitySurface_ForwardsToLifecycleState() + { + SessionModel session = CreateSession(); + + session.AgentRunState = AgentRunStateEnum.Running; + session.SdkState = SdkSessionStateEnum.Resumed; + session.ModelChanged = true; + session.AgentChanged = true; + session.AgentModeChanged = true; + session.SuppressFinishedNotification = true; + + session.Lifecycle.AgentRunState.ShouldBe(AgentRunStateEnum.Running); + session.Lifecycle.SdkState.ShouldBe(SdkSessionStateEnum.Resumed); + session.Lifecycle.ModelChanged.ShouldBeTrue(); + session.Lifecycle.AgentChanged.ShouldBeTrue(); + session.Lifecycle.AgentModeChanged.ShouldBeTrue(); + session.Lifecycle.SuppressFinishedNotification.ShouldBeTrue(); + } + + [Fact] + public void DisplayStatus_UsesLifecycleRunState() + { + SessionModel session = CreateSession(); + + session.Lifecycle.AgentRunState = AgentRunStateEnum.Running; + + session.DisplayStatus.ShouldBe(SessionStatusEnum.Running); + session.Status.ShouldBe(SessionStatusEnum.Running); + } +} diff --git a/Tests/Cockpit.UnitTests/Features/Sessions/SessionListFeatureTests.cs b/Tests/Cockpit.UnitTests/Features/Sessions/SessionListFeatureTests.cs index 6e6ae196..7db2d7e9 100644 --- a/Tests/Cockpit.UnitTests/Features/Sessions/SessionListFeatureTests.cs +++ b/Tests/Cockpit.UnitTests/Features/Sessions/SessionListFeatureTests.cs @@ -14,7 +14,7 @@ public class SessionListFeatureTests { Id = id, Title = title, - Status = SessionStatusEnum.Idle, + AgentRunState = AgentRunStateEnum.Idle, CreatedAt = DateTime.UtcNow, LastActivity = DateTime.UtcNow, Model = testModel, diff --git a/Tests/Cockpit.UnitTests/Features/Sessions/SessionStatusBehaviourTests.cs b/Tests/Cockpit.UnitTests/Features/Sessions/SessionStatusBehaviourTests.cs new file mode 100644 index 00000000..10d207f0 --- /dev/null +++ b/Tests/Cockpit.UnitTests/Features/Sessions/SessionStatusBehaviourTests.cs @@ -0,0 +1,144 @@ +using Cockpit.Components.Controls; +using Cockpit.Features.ElicitationRequests; +using Cockpit.Features.Permissions; +using Cockpit.Features.Permissions.Models; +using Cockpit.Features.Sessions; +using Cockpit.Features.Sessions.Interactions; +using Cockpit.Features.Sessions.Models; +using Cockpit.Features.UserInputRequests; +using GitHub.Copilot; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace Cockpit.UnitTests.Features.Sessions; + +public sealed class SessionStatusBehaviourTests +{ + const string sessionId = "status-session"; + static readonly ModelInfo testModel = new() { Id = "test", Name = "Test Model" }; + + sealed class TestSessionStateProvider : ISessionStateProvider + { + readonly List _sessions = []; + + public event Action? OnStateChanged; + public IReadOnlyList Sessions => _sessions; + public SessionModel? CurrentSession => _sessions.FirstOrDefault(); + + public void Add(SessionModel session) => _sessions.Add(session); + public void NotifyStateChanged() => OnStateChanged?.Invoke(); + } + + static SessionModel CreateRunningSession() => new() + { + Id = sessionId, + Title = "Status behaviour", + CreatedAt = DateTime.UtcNow, + LastActivity = DateTime.UtcNow, + Model = testModel, + AgentRunState = AgentRunStateEnum.Running, + Context = new() + { + CurrentWorkingDirectory = string.Empty, + WorkspacePath = null, + GitRoot = null, + Repository = null, + Branch = null + } + }; + + [Theory] + [InlineData(SessionStatusEnum.Idle, "Idle", "idle", "secondary-text")] + [InlineData(SessionStatusEnum.Running, "Running", "running", "status-running")] + [InlineData(SessionStatusEnum.Error, "Error", "error", "secondary-text")] + [InlineData(SessionStatusEnum.NeedsPermission, "Permission required", "needspermission", "status-needs-permission")] + [InlineData(SessionStatusEnum.NeedsUserInput, "Input requested", "needsuserinput", "status-needs-user-input")] + [InlineData(SessionStatusEnum.NeedsElicitation, "Input requested", "needselicitation", "status-needs-user-input")] + public void Presentation_PreservesCurrentLabelsAndClasses( + SessionStatusEnum status, + string headerText, + string headerClass, + string listClass) + { + SessionStatusPresentation.GetHeaderText(status).ShouldBe(headerText); + SessionStatusPresentation.GetHeaderClass(status).ShouldBe(headerClass); + SessionStatusPresentation.GetListClass(status).ShouldBe(listClass); + } + + [Fact] + public async Task ConcurrentInteractionTypes_PreserveCurrentVisibleStatusTransitions() + { + TestSessionStateProvider stateProvider = new(); + SessionModel session = CreateRunningSession(); + stateProvider.Add(session); + + string permissionsPath = Path.Combine(Path.GetTempPath(), $"phase1-permissions-{Guid.NewGuid()}.json"); + string denyPath = Path.Combine(Path.GetTempPath(), $"phase1-deny-{Guid.NewGuid()}.json"); + SessionPermissionFeature sessionPermissions = new(stateProvider); + SessionInteractionCoordinator interactionCoordinator = new(stateProvider); + PermissionFeature permissionFeature = new( + new GlobalPermissionFeature(NullLogger.Instance, permissionsPath), + new GlobalDenyFeature(NullLogger.Instance, denyPath), + sessionPermissions, + stateProvider, + NullLogger.Instance, + interactionCoordinator); + UserInputFeature userInputFeature = new(stateProvider, NullLogger.Instance, interactionCoordinator); + ElicitationFeature elicitationFeature = new(stateProvider, NullLogger.Instance, interactionCoordinator); + + TaskCompletionSource permissionReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + permissionFeature.OnPermissionRequested += (_, request) => permissionReady.TrySetResult(request); + + PermissionRequestModel permissionRequest = new() + { + SessionId = sessionId, + FullCommand = "dangerous-phase1-command", + Commands = ["dangerous-phase1-command"], + RequestTitle = "Allow command", + Intention = "Characterize concurrent status behaviour", + CanApproveGlobally = true, + CanApproveForSession = true, + IsDestructive = true, + FullRequestJson = "{}" + }; + + Task permissionTask = permissionFeature.CheckPermissionAsync(permissionRequest); + await permissionReady.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + session.Status.ShouldBe(SessionStatusEnum.NeedsPermission); + + TaskCompletionSource userInputReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + userInputFeature.OnUserInputRequested += (_, request) => userInputReady.TrySetResult(request); + Task userInputTask = userInputFeature.HandleUserInputRequest( + new UserInputRequest { Question = "Continue?", Choices = [], AllowFreeform = true }, + new UserInputInvocation { SessionId = sessionId }); + UserInputRequestModel userInputRequest = await userInputReady.Task + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + session.Status.ShouldBe(SessionStatusEnum.NeedsUserInput); + + TaskCompletionSource elicitationReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + elicitationFeature.OnElicitationRequested += (_, request) => elicitationReady.TrySetResult(request); + Task elicitationTask = elicitationFeature.HandleElicitationRequest(new ElicitationContext + { + SessionId = sessionId, + Message = "Provide details", + ElicitationSource = "phase1-test" + }); + ElicitationRequestModel elicitationRequest = await elicitationReady.Task + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + session.Status.ShouldBe(SessionStatusEnum.NeedsElicitation); + + // The existing resolution rules prioritise permission, then user input, before + // restoring the run state saved when the first blocking request arrived. + elicitationFeature.ResolveElicitationRequest(elicitationRequest.Id, null); + await elicitationTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + session.Status.ShouldBe(SessionStatusEnum.NeedsPermission); + + permissionFeature.ResolvePermissionRequest(permissionRequest.Id, PermissionDecisionEnum.Denied); + await permissionTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + session.Status.ShouldBe(SessionStatusEnum.NeedsUserInput); + + userInputFeature.ResolveUserInputRequest(userInputRequest.Id, null); + await userInputTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + session.Status.ShouldBe(SessionStatusEnum.Running); + } +} diff --git a/Tests/Cockpit.UnitTests/Features/Sessions/SessionUiStateTests.cs b/Tests/Cockpit.UnitTests/Features/Sessions/SessionUiStateTests.cs new file mode 100644 index 00000000..f9837710 --- /dev/null +++ b/Tests/Cockpit.UnitTests/Features/Sessions/SessionUiStateTests.cs @@ -0,0 +1,63 @@ +using Cockpit.Features.Sessions.Models; +using GitHub.Copilot; +using Shouldly; + +namespace Cockpit.UnitTests.Features.Sessions; + +public sealed class SessionUiStateTests +{ + static SessionModel CreateSession(string id) => new() + { + Id = id, + Title = "UI state", + CreatedAt = DateTime.UtcNow, + LastActivity = DateTime.UtcNow, + Model = new ModelInfo { Id = "test", Name = "Test Model" }, + Context = new() + { + CurrentWorkingDirectory = string.Empty, + WorkspacePath = null, + GitRoot = null, + Repository = null, + Branch = null + } + }; + + [Fact] + public void CompatibilitySurface_ForwardsToUiState() + { + SessionModel session = CreateSession("ui-session"); + List attachments = + [ + new("file.txt", "file.txt", "data:text/plain;base64,dGVzdA==", "text/plain") + ]; + + session.IsYolo = true; + session.IsTerminalOpen = true; + session.UserInput = "draft"; + session.PendingAttachments = attachments; + session.UserInputResponseText = "response"; + + session.Ui.IsYolo.ShouldBeTrue(); + session.Ui.IsTerminalOpen.ShouldBeTrue(); + session.Ui.DraftText.ShouldBe("draft"); + session.Ui.PendingAttachments.ShouldBeSameAs(attachments); + session.Ui.UserInputResponseText.ShouldBe("response"); + (session.PendingAttachmentsLock == session.Ui.PendingAttachmentsLock).ShouldBeTrue(); + } + + [Fact] + public void UiState_IsScopedToItsSession() + { + SessionModel first = CreateSession("first"); + SessionModel second = CreateSession("second"); + + first.Ui.DraftText = "first draft"; + first.Ui.PendingAttachments.Add( + new AttachmentModel("file.txt", "file.txt", "data:text/plain;base64,dGVzdA==", "text/plain")); + + second.Ui.DraftText.ShouldBeEmpty(); + second.Ui.PendingAttachments.ShouldBeEmpty(); + (first.Ui.PendingAttachmentsLock == second.Ui.PendingAttachmentsLock).ShouldBeFalse(); + } +} diff --git a/Tests/Cockpit.UnitTests/Features/Update/UpdateFeatureTests.cs b/Tests/Cockpit.UnitTests/Features/Update/UpdateFeatureTests.cs index f79ff404..cee0af8f 100644 --- a/Tests/Cockpit.UnitTests/Features/Update/UpdateFeatureTests.cs +++ b/Tests/Cockpit.UnitTests/Features/Update/UpdateFeatureTests.cs @@ -655,12 +655,9 @@ public void IsExpectedInstallerDownload_AcceptsVersionTagWithVPrefix() [Fact] public void IsSessionActive_ReturnsTrue_ForRunningAndBlockingStates() { - UpdateFeature.IsSessionActive(SessionStatusEnum.Running).ShouldBeTrue(); - UpdateFeature.IsSessionActive(SessionStatusEnum.NeedsPermission).ShouldBeTrue(); - UpdateFeature.IsSessionActive(SessionStatusEnum.NeedsUserInput).ShouldBeTrue(); - UpdateFeature.IsSessionActive(SessionStatusEnum.NeedsElicitation).ShouldBeTrue(); - UpdateFeature.IsSessionActive(SessionStatusEnum.Idle).ShouldBeFalse(); - UpdateFeature.IsSessionActive(SessionStatusEnum.Error).ShouldBeFalse(); + UpdateFeature.IsSessionActive(AgentRunStateEnum.Running).ShouldBeTrue(); + UpdateFeature.IsSessionActive(AgentRunStateEnum.Idle).ShouldBeFalse(); + UpdateFeature.IsSessionActive(AgentRunStateEnum.Error).ShouldBeFalse(); } #endregion diff --git a/Tests/Cockpit.UnitTests/Features/UserInputRequests/UserInputFeatureTests.cs b/Tests/Cockpit.UnitTests/Features/UserInputRequests/UserInputFeatureTests.cs index 33479e52..3813dda6 100644 --- a/Tests/Cockpit.UnitTests/Features/UserInputRequests/UserInputFeatureTests.cs +++ b/Tests/Cockpit.UnitTests/Features/UserInputRequests/UserInputFeatureTests.cs @@ -33,7 +33,7 @@ sealed class TestSessionStateProvider : ISessionStateProvider CreatedAt = DateTime.UtcNow, LastActivity = DateTime.UtcNow, Model = testModel, - Status = SessionStatusEnum.Idle, + AgentRunState = AgentRunStateEnum.Idle, Context = new() { CurrentWorkingDirectory = "", @@ -291,17 +291,17 @@ public async Task SessionStatus_UpdatedToNeedsUserInputWhilePending() BuildRequest(), BuildInvocation()); - session.Status.ShouldBe(SessionStatusEnum.NeedsUserInput); + session.DisplayStatus.ShouldBe(SessionStatusEnum.NeedsUserInput); feature.ResolveUserInputRequest(model.Id, null); await handleTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); } [Fact] - public async Task SessionStatus_RestoredAfterResolve() + public async Task SessionStatus_RevealsRunStateAfterResolve() { (UserInputFeature feature, SessionModel session, _) = CreateFeature(); - session.Status = SessionStatusEnum.Running; + session.AgentRunState = AgentRunStateEnum.Running; (Task handleTask, UserInputRequestModel model) = await StartHandleAsync( feature, @@ -311,7 +311,7 @@ public async Task SessionStatus_RestoredAfterResolve() feature.ResolveUserInputRequest(model.Id, "yes"); await handleTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); - // Status should be restored to the pre-request status + // Resolving the interaction reveals the independently tracked run state. session.Status.ShouldBe(SessionStatusEnum.Running); } @@ -325,12 +325,12 @@ public async Task SessionStatus_PendingRequestAddedToSession() BuildRequest(), BuildInvocation()); - session.PendingUserInputRequests.ContainsKey(model.Id).ShouldBeTrue(); + session.PendingInteractions.UserInputs.ContainsKey(model.Id).ShouldBeTrue(); feature.ResolveUserInputRequest(model.Id, null); await handleTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); - session.PendingUserInputRequests.ContainsKey(model.Id).ShouldBeFalse(); + session.PendingInteractions.UserInputs.ContainsKey(model.Id).ShouldBeFalse(); } // ── Cancellation ───────────────────────────────────────────────────────── @@ -404,18 +404,18 @@ public async Task ConcurrentRequests_ResolvedIndependently() } [Fact] - public async Task ConcurrentRequests_SecondRequestDoesNotPushStatusHistoryAgain() + public async Task ConcurrentRequests_PreserveRunStateUntilAllRequestsResolve() { (UserInputFeature feature, SessionModel session, _) = CreateFeature(); - session.Status = SessionStatusEnum.Running; + session.AgentRunState = AgentRunStateEnum.Running; (Task task1, UserInputRequestModel model1) = await StartHandleAsync( feature, BuildRequest(question: "First?"), BuildInvocation()); (Task task2, UserInputRequestModel model2) = await StartHandleAsync( feature, BuildRequest(question: "Second?"), BuildInvocation()); - // Both pending — status history should only have one entry pushed - session.StatusHistory.Count.ShouldBe(1); + // Pending interactions change the display status without changing lifecycle state. + session.AgentRunState.ShouldBe(AgentRunStateEnum.Running); session.Status.ShouldBe(SessionStatusEnum.NeedsUserInput); feature.ResolveUserInputRequest(model1.Id, "one"); @@ -429,6 +429,7 @@ public async Task ConcurrentRequests_SecondRequestDoesNotPushStatusHistoryAgain( // All resolved — should restore Running session.Status.ShouldBe(SessionStatusEnum.Running); + session.AgentRunState.ShouldBe(AgentRunStateEnum.Running); } // ── ToRequestModel ──────────────────────────────────────────────────────── diff --git a/src/Cockpit/Components/Controls/SessionStatusBadge.razor b/src/Cockpit/Components/Controls/SessionStatusBadge.razor new file mode 100644 index 00000000..83badf07 --- /dev/null +++ b/src/Cockpit/Components/Controls/SessionStatusBadge.razor @@ -0,0 +1,47 @@ +@using Cockpit.Features.Sessions.Models + +@if(Status is SessionStatusEnum status) +{ + @if(Variant == SessionStatusVariant.Header) + { + + @SessionStatusPresentation.GetHeaderText(status) + + } + else + { +
+ @if(status == SessionStatusEnum.NeedsPermission) + { + + + + Permission required + } + else if(status is SessionStatusEnum.NeedsUserInput or SessionStatusEnum.NeedsElicitation) + { + + + + Input requested + } + else if(status == SessionStatusEnum.Running) + { + + + + Agent running... + } + else + { + @FallbackText + } +
+ } +} + +@code { + [Parameter] public SessionStatusEnum? Status { get; set; } + [Parameter] public SessionStatusVariant Variant { get; set; } + [Parameter] public string? FallbackText { get; set; } +} diff --git a/src/Cockpit/Components/Pages/ChatPanel/ChatPanel.razor.css b/src/Cockpit/Components/Controls/SessionStatusBadge.razor.css similarity index 55% rename from src/Cockpit/Components/Pages/ChatPanel/ChatPanel.razor.css rename to src/Cockpit/Components/Controls/SessionStatusBadge.razor.css index 5f5736d4..122d02b3 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/ChatPanel.razor.css +++ b/src/Cockpit/Components/Controls/SessionStatusBadge.razor.css @@ -9,28 +9,14 @@ color: var(--secondary-text); } - .status-badge.agentrunning { - background-color: #0078D4; - color: #fff; - } - - .status-badge.agentfinished { - background-color: #2B2B2B; - color: #CCCCCC; - } - - .status-badge.idle, - .status-badge.active, - .status-badge.error, - .status-badge.archived { - background-color: var(--active-color); - color: var(--secondary-text); - } +.status-badge.idle, +.status-badge.error { + background-color: var(--active-color); + color: var(--secondary-text); +} .light-theme .status-badge.idle, -.light-theme .status-badge.active, -.light-theme .status-badge.error, -.light-theme .status-badge.archived { +.light-theme .status-badge.error { background-color: var(--active-color); color: var(--secondary-text); } @@ -47,14 +33,21 @@ border: 1px solid #ef4444; } -.status-badge.needsuserinput { +.status-badge.needsuserinput, +.status-badge.needselicitation { background-color: rgba(59, 130, 246, 0.1); color: #3b82f6; border: 1px solid #3b82f6; } -.status-badge.needselicitation { - background-color: rgba(59, 130, 246, 0.1); +.status-needs-permission { + color: #FFA500; +} + +.status-needs-user-input { color: #3b82f6; - border: 1px solid #3b82f6; +} + +.status-running { + color: #FFB900; } diff --git a/src/Cockpit/Components/Controls/SessionStatusPresentation.cs b/src/Cockpit/Components/Controls/SessionStatusPresentation.cs new file mode 100644 index 00000000..dc30344d --- /dev/null +++ b/src/Cockpit/Components/Controls/SessionStatusPresentation.cs @@ -0,0 +1,23 @@ +using Cockpit.Features.Sessions.Models; + +namespace Cockpit.Components.Controls; + +static class SessionStatusPresentation +{ + internal static string GetHeaderText(SessionStatusEnum status) => status switch + { + SessionStatusEnum.NeedsPermission => "Permission required", + SessionStatusEnum.NeedsUserInput or SessionStatusEnum.NeedsElicitation => "Input requested", + _ => status.ToString() + }; + + internal static string GetHeaderClass(SessionStatusEnum status) => status.ToString().ToLowerInvariant(); + + internal static string GetListClass(SessionStatusEnum status) => status switch + { + SessionStatusEnum.NeedsPermission => "status-needs-permission", + SessionStatusEnum.NeedsUserInput or SessionStatusEnum.NeedsElicitation => "status-needs-user-input", + SessionStatusEnum.Running => "status-running", + _ => "secondary-text" + }; +} diff --git a/src/Cockpit/Components/Controls/SessionStatusVariant.cs b/src/Cockpit/Components/Controls/SessionStatusVariant.cs new file mode 100644 index 00000000..d65c1554 --- /dev/null +++ b/src/Cockpit/Components/Controls/SessionStatusVariant.cs @@ -0,0 +1,7 @@ +namespace Cockpit.Components.Controls; + +public enum SessionStatusVariant +{ + Header, + SessionList +} diff --git a/src/Cockpit/Components/Pages/ChatPanel/ChatMessages.razor b/src/Cockpit/Components/Pages/ChatPanel/ChatMessages.razor index 5192abf9..317ff493 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/ChatMessages.razor +++ b/src/Cockpit/Components/Pages/ChatPanel/ChatMessages.razor @@ -3,9 +3,9 @@
- @if(_sessionListFeature.CurrentSession?.MessagesSnapshot is not null) + @if(_sessionListFeature.CurrentSession?.Conversation.MessagesSnapshot is not null) { - @foreach(var message in _sessionListFeature.CurrentSession.MessagesSnapshot) + @foreach(var message in _sessionListFeature.CurrentSession.Conversation.MessagesSnapshot) { if(message.Type == MessageTypeEnum.ActivityGroup && message.ActivityGroup is not null) { diff --git a/src/Cockpit/Components/Pages/ChatPanel/ChatMessages.razor.cs b/src/Cockpit/Components/Pages/ChatPanel/ChatMessages.razor.cs index 3f4889ad..42f71683 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/ChatMessages.razor.cs +++ b/src/Cockpit/Components/Pages/ChatPanel/ChatMessages.razor.cs @@ -65,7 +65,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // If the app loaded an existing session before this component initialized, // ensure the initial view is pinned to the bottom so history shows latest messages. - if(!_pendingScrollToBottom && _sessionListFeature.CurrentSession?.Messages is not null && _sessionListFeature.CurrentSession.Messages.Count > 0) + if(!_pendingScrollToBottom && _sessionListFeature.CurrentSession?.Conversation.MessagesSnapshot.Length > 0) { await ScrollToBottom(); } diff --git a/src/Cockpit/Components/Pages/ChatPanel/ChatPanel.razor b/src/Cockpit/Components/Pages/ChatPanel/ChatPanel.razor index a2ed5f72..28243f19 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/ChatPanel.razor +++ b/src/Cockpit/Components/Pages/ChatPanel/ChatPanel.razor @@ -4,6 +4,7 @@ @using Cockpit.Components.Pages.ChatPanel.Permissions @using Cockpit.Components.Pages.ChatPanel.Terminal @using Cockpit.Components.Pages.ChatPanel.UserInputRequest +@using Cockpit.Components.Controls
@@ -17,31 +18,15 @@

@_sessionFeature.CurrentSession?.Title - - @if(_sessionFeature.CurrentSession?.Status == SessionStatusEnum.NeedsPermission) - { - @:Permission required - } - else if(_sessionFeature.CurrentSession?.Status == SessionStatusEnum.NeedsUserInput) - { - @:Input requested - } - else if(_sessionFeature.CurrentSession?.Status == SessionStatusEnum.NeedsElicitation) - { - @:Input requested - } - else - { - @_sessionFeature.CurrentSession?.Status - } - +

-
diff --git a/src/Cockpit/Components/Pages/ChatPanel/ChatPanel.razor.cs b/src/Cockpit/Components/Pages/ChatPanel/ChatPanel.razor.cs index a21eabfc..9b3454c9 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/ChatPanel.razor.cs +++ b/src/Cockpit/Components/Pages/ChatPanel/ChatPanel.razor.cs @@ -1,3 +1,4 @@ +using Cockpit.Features.SessionEvents.Models; using Cockpit.Features.Sessions; using Cockpit.Features.Timestamp; using Cockpit.Features.UIState; @@ -59,7 +60,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await SetupSmartScroll(); // Initialize message count - _lastMessageCount = _sessionFeature.CurrentSession?.Messages.Count ?? 0; + _lastMessageCount = _sessionFeature.CurrentSession?.Conversation.MessagesSnapshot.Length ?? 0; _lastSessionId = _sessionFeature.CurrentSession?.Id; } @@ -74,7 +75,9 @@ protected override async Task OnAfterRenderAsync(bool firstRender) void OnStateChanged() { string? currentSessionId = _sessionFeature.CurrentSession?.Id; - int currentMessageCount = _sessionFeature.CurrentSession?.Messages.Count ?? 0; + IReadOnlyList? messages = + _sessionFeature.CurrentSession?.Conversation.MessagesSnapshot; + int currentMessageCount = messages?.Count ?? 0; if(currentSessionId != _lastSessionId) { @@ -87,7 +90,7 @@ void OnStateChanged() if(currentMessageCount > _lastMessageCount) { _shouldScrollToBottom = true; - if(_sessionFeature.CurrentSession?.Messages.LastOrDefault()?.IsUser == true) + if(messages?.LastOrDefault()?.IsUser == true) { // Always jump to latest when user sends a message _isUserScrolledUpFromChat = false; @@ -133,7 +136,7 @@ void ToggleTerminalPanel() { if(_sessionFeature.CurrentSession is not null) { - _sessionFeature.CurrentSession.IsTerminalOpen = !_sessionFeature.CurrentSession.IsTerminalOpen; + _sessionFeature.CurrentSession.Ui.IsTerminalOpen = !_sessionFeature.CurrentSession.Ui.IsTerminalOpen; StateHasChanged(); } } diff --git a/src/Cockpit/Components/Pages/ChatPanel/ElicitationRequest/ElicitationRequestPanel.razor.cs b/src/Cockpit/Components/Pages/ChatPanel/ElicitationRequest/ElicitationRequestPanel.razor.cs index b01ef0b4..2ae651f7 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/ElicitationRequest/ElicitationRequestPanel.razor.cs +++ b/src/Cockpit/Components/Pages/ChatPanel/ElicitationRequest/ElicitationRequestPanel.razor.cs @@ -28,7 +28,7 @@ protected override void OnInitialized() _sessionListFeature.OnStateChanged += OnStateChanged; } - public ElicitationRequestModel? Request => _sessionListFeature.CurrentSession?.PendingElicitationRequests.Values.OrderBy(r => r.Requested).FirstOrDefault(); + public ElicitationRequestModel? Request => _sessionListFeature.CurrentSession?.PendingInteractions.Elicitations.Values.OrderBy(r => r.Requested).FirstOrDefault(); bool IsUrlOnly => Request?.Mode?.Value == ElicitationRequestedMode.Url.Value; bool IsInline => IsUrlOnly || Request?.Fields.Length <= 4; diff --git a/src/Cockpit/Components/Pages/ChatPanel/InputArea/AgentControl.razor b/src/Cockpit/Components/Pages/ChatPanel/InputArea/AgentControl.razor index 17e2466d..3af489e4 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/InputArea/AgentControl.razor +++ b/src/Cockpit/Components/Pages/ChatPanel/InputArea/AgentControl.razor @@ -2,8 +2,8 @@ + IsDisabled="@(_sessionListFeature.CurrentSession?.DisplayStatus == SessionStatusEnum.Running || _sessionListFeature.CurrentSession?.PendingInteractions.Permissions.Count > 0)" + TooltipText="@(_sessionListFeature.CurrentSession?.DisplayStatus == SessionStatusEnum.Running ? "You can't change agent while the agent is working" : null)"> diff --git a/src/Cockpit/Components/Pages/ChatPanel/InputArea/AgentControl.razor.cs b/src/Cockpit/Components/Pages/ChatPanel/InputArea/AgentControl.razor.cs index b13e0f20..1fcae481 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/InputArea/AgentControl.razor.cs +++ b/src/Cockpit/Components/Pages/ChatPanel/InputArea/AgentControl.razor.cs @@ -66,7 +66,7 @@ void SelectAgent(AgentProfile? agent) } _sessionListFeature.CurrentSession.Context.SelectedAgent = agent; - _sessionListFeature.CurrentSession.AgentChanged = true; + _sessionListFeature.CurrentSession.Lifecycle.AgentChanged = true; // Persist agent selection immediately _ = _agentPersistence.SaveSessionAgent(_sessionListFeature.CurrentSession); diff --git a/src/Cockpit/Components/Pages/ChatPanel/InputArea/AttachmentsControl.razor.cs b/src/Cockpit/Components/Pages/ChatPanel/InputArea/AttachmentsControl.razor.cs index b89149d7..4a3ffe27 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/InputArea/AttachmentsControl.razor.cs +++ b/src/Cockpit/Components/Pages/ChatPanel/InputArea/AttachmentsControl.razor.cs @@ -32,14 +32,14 @@ IReadOnlyList AttachmentsSnapshot return []; } - lock(Session.PendingAttachmentsLock) + lock(Session.Ui.PendingAttachmentsLock) { // Deduplicate by file path across all attachment types. // Non-mention (manually added) entries take priority so the user can remove them. HashSet seen = new(StringComparer.OrdinalIgnoreCase); List result = []; - foreach(AttachmentModel att in Session.PendingAttachments) + foreach(AttachmentModel att in Session.Ui.PendingAttachments) { if(!att.IsMention && seen.Add(att.FilePath)) { @@ -47,7 +47,7 @@ IReadOnlyList AttachmentsSnapshot } } - foreach(AttachmentModel att in Session.PendingAttachments) + foreach(AttachmentModel att in Session.Ui.PendingAttachments) { if(att.IsMention && seen.Add(att.FilePath)) { @@ -76,9 +76,9 @@ Task RemoveAttachment(AttachmentModel target) return Task.CompletedTask; } - lock(session.PendingAttachmentsLock) + lock(session.Ui.PendingAttachmentsLock) { - session.PendingAttachments.Remove(target); + session.Ui.PendingAttachments.Remove(target); } StateHasChanged(); diff --git a/src/Cockpit/Components/Pages/ChatPanel/InputArea/ChatInputArea.razor b/src/Cockpit/Components/Pages/ChatPanel/InputArea/ChatInputArea.razor index 8f871b43..77b7c9b9 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/InputArea/ChatInputArea.razor +++ b/src/Cockpit/Components/Pages/ChatPanel/InputArea/ChatInputArea.razor @@ -2,7 +2,7 @@
- @if(_sessionFeature.CurrentSession?.PendingPermissionRequests?.Count > 0 || _sessionFeature.CurrentSession?.PendingUserInputRequests?.Count > 0 || _sessionFeature.CurrentSession?.PendingElicitationRequests?.Count > 0) + @if(_sessionFeature.CurrentSession?.PendingInteractions.Permissions.Count > 0 || _sessionFeature.CurrentSession?.PendingInteractions.UserInputs.Count > 0 || _sessionFeature.CurrentSession?.PendingInteractions.Elicitations.Count > 0) {
} @@ -58,9 +58,9 @@
diff --git a/src/Cockpit/Components/Pages/ChatPanel/InputArea/ChatInputArea.razor.cs b/src/Cockpit/Components/Pages/ChatPanel/InputArea/ChatInputArea.razor.cs index 55d7ee98..6a0983c0 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/InputArea/ChatInputArea.razor.cs +++ b/src/Cockpit/Components/Pages/ChatPanel/InputArea/ChatInputArea.razor.cs @@ -54,14 +54,14 @@ public ChatInputArea( CancellationTokenSource? _mentionSearchCts; bool IsInputDisabled => - _sessionFeature.CurrentSession?.PendingPermissionRequests?.Count > 0 - || _sessionFeature.CurrentSession?.PendingUserInputRequests?.Count > 0 - || _sessionFeature.CurrentSession?.PendingElicitationRequests?.Count > 0; + _sessionFeature.CurrentSession?.PendingInteractions.Permissions.Count > 0 + || _sessionFeature.CurrentSession?.PendingInteractions.UserInputs.Count > 0 + || _sessionFeature.CurrentSession?.PendingInteractions.Elicitations.Count > 0; string UserInput { - get => _sessionFeature.CurrentSession?.UserInput ?? string.Empty; - set => _sessionFeature.CurrentSession?.UserInput = value; + get => _sessionFeature.CurrentSession?.Ui.DraftText ?? string.Empty; + set => _sessionFeature.CurrentSession?.Ui.DraftText = value; } protected override void OnInitialized() @@ -173,12 +173,12 @@ async Task SyncDomFromUserInput() SessionModel? session = _sessionFeature.CurrentSession; if(session is not null) { - lock(session.PendingAttachmentsLock) + lock(session.Ui.PendingAttachmentsLock) { // Only clear/rebuild mention attachments when parsing actually returns chips if(chips.Length > 0) { - session.PendingAttachments.RemoveAll(a => a.IsMention); + session.Ui.PendingAttachments.RemoveAll(a => a.IsMention); foreach(ChipInfo chip in chips) { @@ -186,7 +186,7 @@ async Task SyncDomFromUserInput() string ext = Path.GetExtension(chip.FilePath); string mimeType = FileUtil.GetMimeType(ext); bool isDirectory = Directory.Exists(chip.FilePath); - session.PendingAttachments.Add(new AttachmentModel( + session.Ui.PendingAttachments.Add(new AttachmentModel( fileName, chip.FilePath, null, mimeType, isMention: true, chipId: chip.ChipId, isDirectory: isDirectory)); } @@ -283,9 +283,9 @@ public async Task OnContentInput() List combined = [.. searchResults]; HashSet seenPaths = new(combined.Select(f => f.FullPath), StringComparer.OrdinalIgnoreCase); - lock(session.PendingAttachmentsLock) + lock(session.Ui.PendingAttachmentsLock) { - foreach(AttachmentModel att in session.PendingAttachments) + foreach(AttachmentModel att in session.Ui.PendingAttachments) { if(att.IsMention || seenPaths.Contains(att.FilePath)) { @@ -352,12 +352,12 @@ public Task OnChipRemoved(string chipId) return Task.CompletedTask; } - lock(session.PendingAttachmentsLock) + lock(session.Ui.PendingAttachmentsLock) { - int idx = session.PendingAttachments.FindIndex(a => a.ChipId == chipId); + int idx = session.Ui.PendingAttachments.FindIndex(a => a.ChipId == chipId); if(idx >= 0) { - session.PendingAttachments.RemoveAt(idx); + session.Ui.PendingAttachments.RemoveAt(idx); } } @@ -387,9 +387,9 @@ async Task OnFileSelectedAsync(FileSearchResult file) string ext = Path.GetExtension(file.FileName); string mimeType = FileUtil.GetMimeType(ext); - lock(session.PendingAttachmentsLock) + lock(session.Ui.PendingAttachmentsLock) { - session.PendingAttachments.Add(new AttachmentModel( + session.Ui.PendingAttachments.Add(new AttachmentModel( file.FileName, file.FullPath, null, mimeType, isMention: true, chipId: chipId, isDirectory: file.IsDirectory)); } @@ -457,12 +457,12 @@ public async Task OnImagePasted(string base64, string mimeType, string ext, stri string dataUri = $"data:{mimeType};base64,{base64}"; bool isDuplicate; - lock(session.PendingAttachmentsLock) + lock(session.Ui.PendingAttachmentsLock) { - isDuplicate = session.PendingAttachments.Any(a => a.DataUri == dataUri); + isDuplicate = session.Ui.PendingAttachments.Any(a => a.DataUri == dataUri); if(!isDuplicate) { - session.PendingAttachments.Add(new AttachmentModel(fileName, filePath, dataUri, mimeType)); + session.Ui.PendingAttachments.Add(new AttachmentModel(fileName, filePath, dataUri, mimeType)); } } @@ -527,15 +527,15 @@ async Task AttachFiles() } } - lock(session.PendingAttachmentsLock) + lock(session.Ui.PendingAttachmentsLock) { - if(session.PendingAttachments.Any(a => string.Equals(a.FilePath, filePath, StringComparison.OrdinalIgnoreCase))) + if(session.Ui.PendingAttachments.Any(a => string.Equals(a.FilePath, filePath, StringComparison.OrdinalIgnoreCase))) { _toastService.Info("Already attached", opts => opts.Description = $"{result.FileName} is already attached."); continue; } - session.PendingAttachments.Add(new AttachmentModel(result.FileName, filePath, dataUri, mimeType)); + session.Ui.PendingAttachments.Add(new AttachmentModel(result.FileName, filePath, dataUri, mimeType)); } } @@ -548,8 +548,8 @@ async Task AttachFiles() } bool CanSend => !string.IsNullOrWhiteSpace(UserInput) - && _sessionFeature.CurrentSession?.PendingPermissionRequests?.Count == 0 - && _sessionFeature.CurrentSession?.PendingElicitationRequests?.Count == 0; + && _sessionFeature.CurrentSession?.PendingInteractions.Permissions.Count == 0 + && _sessionFeature.CurrentSession?.PendingInteractions.Elicitations.Count == 0; async Task SendMessage() { @@ -577,12 +577,12 @@ async Task SendMessage() List? attachments = null; if(session is not null) { - lock(session.PendingAttachmentsLock) + lock(session.Ui.PendingAttachmentsLock) { - if(session.PendingAttachments.Count > 0) + if(session.Ui.PendingAttachments.Count > 0) { - attachments = [.. session.PendingAttachments]; - session.PendingAttachments.Clear(); + attachments = [.. session.Ui.PendingAttachments]; + session.Ui.PendingAttachments.Clear(); } } } @@ -648,9 +648,9 @@ HashSet GetAttachedPaths() { return []; } - lock(session.PendingAttachmentsLock) + lock(session.Ui.PendingAttachmentsLock) { - return session.PendingAttachments.Select(a => a.FilePath).ToHashSet(StringComparer.OrdinalIgnoreCase); + return session.Ui.PendingAttachments.Select(a => a.FilePath).ToHashSet(StringComparer.OrdinalIgnoreCase); } } @@ -671,7 +671,7 @@ void ToggleYoloMode() return; } - _sessionFeature.CurrentSession.IsYolo = !_sessionFeature.CurrentSession.IsYolo; + _sessionFeature.CurrentSession.Ui.IsYolo = !_sessionFeature.CurrentSession.Ui.IsYolo; StateHasChanged(); } diff --git a/src/Cockpit/Components/Pages/ChatPanel/InputArea/ModelControl.razor b/src/Cockpit/Components/Pages/ChatPanel/InputArea/ModelControl.razor index 9a6204e2..926eddfe 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/InputArea/ModelControl.razor +++ b/src/Cockpit/Components/Pages/ChatPanel/InputArea/ModelControl.razor @@ -6,7 +6,7 @@
@@ -56,8 +56,8 @@ { @GetSelectedReasoningEffortDisplay() @@ -95,4 +95,4 @@ Models="_availableModels" SelectedModel="@_sessionListFeature.CurrentSession?.Model" OnModelSelected="HandleModelSelectedFromPopup" /> -
\ No newline at end of file +
diff --git a/src/Cockpit/Components/Pages/ChatPanel/InputArea/ModelControl.razor.cs b/src/Cockpit/Components/Pages/ChatPanel/InputArea/ModelControl.razor.cs index ae3dc728..a981cf57 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/InputArea/ModelControl.razor.cs +++ b/src/Cockpit/Components/Pages/ChatPanel/InputArea/ModelControl.razor.cs @@ -79,7 +79,7 @@ async Task SelectModel(ModelInfo model) } _sessionListFeature.CurrentSession.Model = model; - _sessionListFeature.CurrentSession.ModelChanged = true; + _sessionListFeature.CurrentSession.Lifecycle.ModelChanged = true; // Track BYOK config ID so the session layer knows to restart (not SetModelAsync) when switching providers ByokModelConfig? byokConfig = _byokFeature.GetAll().FirstOrDefault(c => string.Equals(c.ModelId, model.Id, StringComparison.OrdinalIgnoreCase)); @@ -104,7 +104,7 @@ void UpdateReasoningEffortForSelectedModel() if(_sessionListFeature.CurrentSession.ReasoningEffort != newEffort) { _sessionListFeature.CurrentSession.ReasoningEffort = newEffort; - _sessionListFeature.CurrentSession.ModelChanged = true; + _sessionListFeature.CurrentSession.Lifecycle.ModelChanged = true; } } @@ -128,7 +128,7 @@ async Task SelectReasoningEffort(string effort) } _sessionListFeature.CurrentSession.ReasoningEffort = effort; - _sessionListFeature.CurrentSession.ModelChanged = true; + _sessionListFeature.CurrentSession.Lifecycle.ModelChanged = true; _reasoningPicker?.Close(); @@ -137,12 +137,12 @@ async Task SelectReasoningEffort(string effort) string? GetModelPickerTooltip() { - if(_sessionListFeature.CurrentSession?.Status == SessionStatusEnum.Running) + if(_sessionListFeature.CurrentSession?.DisplayStatus == SessionStatusEnum.Running) { return "You can't change model while the agent is working"; } - if(_sessionListFeature.CurrentSession?.PendingPermissionRequests?.Count > 0) + if(_sessionListFeature.CurrentSession?.PendingInteractions.Permissions.Count > 0) { return "You can't change model while a permission request is pending"; } diff --git a/src/Cockpit/Components/Pages/ChatPanel/InputArea/SessionModeControl.razor b/src/Cockpit/Components/Pages/ChatPanel/InputArea/SessionModeControl.razor index 9911a326..d692f846 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/InputArea/SessionModeControl.razor +++ b/src/Cockpit/Components/Pages/ChatPanel/InputArea/SessionModeControl.razor @@ -3,8 +3,8 @@ + IsDisabled="@(_sessionListFeature.CurrentSession?.DisplayStatus == SessionStatusEnum.Running || _sessionListFeature.CurrentSession?.PendingInteractions.Permissions.Count > 0)" + TooltipText="@(_sessionListFeature.CurrentSession?.DisplayStatus == SessionStatusEnum.Running ? "You can't change mode while the agent is working" : null)"> @switch(GetCurrentMode()) diff --git a/src/Cockpit/Components/Pages/ChatPanel/InputArea/SessionModeControl.razor.cs b/src/Cockpit/Components/Pages/ChatPanel/InputArea/SessionModeControl.razor.cs index 1bc5cf14..37e21fc3 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/InputArea/SessionModeControl.razor.cs +++ b/src/Cockpit/Components/Pages/ChatPanel/InputArea/SessionModeControl.razor.cs @@ -47,7 +47,7 @@ void SelectMode(SessionAgentModeEnum mode) } _sessionListFeature.CurrentSession.Context.SelectedAgentMode = mode; - _sessionListFeature.CurrentSession.AgentModeChanged = true; + _sessionListFeature.CurrentSession.Lifecycle.AgentModeChanged = true; _ = _sessionModePersistence.SaveSessionMode(_sessionListFeature.CurrentSession); diff --git a/src/Cockpit/Components/Pages/ChatPanel/Permissions/PermissionRequestPanel.razor.cs b/src/Cockpit/Components/Pages/ChatPanel/Permissions/PermissionRequestPanel.razor.cs index f5d64753..a7436e88 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/Permissions/PermissionRequestPanel.razor.cs +++ b/src/Cockpit/Components/Pages/ChatPanel/Permissions/PermissionRequestPanel.razor.cs @@ -30,7 +30,7 @@ protected override void OnInitialized() bool _showDropdown = false; PermissionDecisionEnum _selectedAllowOption = PermissionDecisionEnum.Once; PermissionDetailsPopup _moreInfoPopup = default!; - PermissionRequestModel? Request => _sessionManager.CurrentSession?.PendingPermissionRequests?.Values.OrderBy(r => r.Requested).FirstOrDefault(); + PermissionRequestModel? Request => _sessionManager.CurrentSession?.PendingInteractions.Permissions.Values.OrderBy(r => r.Requested).FirstOrDefault(); string GetAllowLabel(PermissionDecisionEnum option) => option switch { @@ -84,4 +84,4 @@ public void Dispose() { _sessionManager.OnStateChanged -= OnStateChanged; } -} \ No newline at end of file +} diff --git a/src/Cockpit/Components/Pages/ChatPanel/UserInputRequest/UserInputRequestPanel.razor.cs b/src/Cockpit/Components/Pages/ChatPanel/UserInputRequest/UserInputRequestPanel.razor.cs index a269e857..a33f1d46 100644 --- a/src/Cockpit/Components/Pages/ChatPanel/UserInputRequest/UserInputRequestPanel.razor.cs +++ b/src/Cockpit/Components/Pages/ChatPanel/UserInputRequest/UserInputRequestPanel.razor.cs @@ -36,11 +36,11 @@ protected override void OnInitialized() public string UserTextInput { - get => _sessionListFeature.CurrentSession?.UserInputResponseText ?? string.Empty; - set => _sessionListFeature.CurrentSession?.UserInputResponseText = value; + get => _sessionListFeature.CurrentSession?.Ui.UserInputResponseText ?? string.Empty; + set => _sessionListFeature.CurrentSession?.Ui.UserInputResponseText = value; } - public UserInputRequestModel? Request => _sessionListFeature.CurrentSession?.PendingUserInputRequests?.Values.OrderBy(r => r.Requested).FirstOrDefault(); + public UserInputRequestModel? Request => _sessionListFeature.CurrentSession?.PendingInteractions.UserInputs.Values.OrderBy(r => r.Requested).FirstOrDefault(); bool CanSubmitText => !string.IsNullOrWhiteSpace(UserTextInput); UserInputRequestDetailsPopup _detailsPopup = default!; @@ -144,7 +144,7 @@ void ClearCurrentSessionInputState() return; } - _sessionListFeature.CurrentSession.UserInputResponseText = string.Empty; + _sessionListFeature.CurrentSession.Ui.UserInputResponseText = string.Empty; } public void Dispose() diff --git a/src/Cockpit/Components/Pages/ContextPanel/TokenUsagePanel.razor.cs b/src/Cockpit/Components/Pages/ContextPanel/TokenUsagePanel.razor.cs index 0110fa4a..fba2e6c8 100644 --- a/src/Cockpit/Components/Pages/ContextPanel/TokenUsagePanel.razor.cs +++ b/src/Cockpit/Components/Pages/ContextPanel/TokenUsagePanel.razor.cs @@ -38,11 +38,11 @@ double UsagePercent bool IsCompactDisabled => CurrentSession is null || CurrentSession.IsCompacting || - CurrentSession.Status == SessionStatusEnum.Running; + CurrentSession.Lifecycle.AgentRunState == AgentRunStateEnum.Running; string CompactTooltip => CurrentSession?.IsCompacting == true ? "Compaction in progress…" - : CurrentSession?.Status == SessionStatusEnum.Running + : CurrentSession?.Lifecycle.AgentRunState == AgentRunStateEnum.Running ? "Cannot compact while the session is busy" : "Compact context window to free up space"; diff --git a/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor b/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor index a951a4ff..218f4393 100644 --- a/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor +++ b/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor @@ -1,10 +1,11 @@ @using Cockpit.Features.Sessions.Models +@using Cockpit.Components.Controls @using Microsoft.AspNetCore.Components.Web -
- @if(Session.SdkState == SdkSessionStateEnum.Loading) + @if(Session.Lifecycle.SdkState == SdkSessionStateEnum.Loading) {
@@ -13,42 +14,11 @@ Loading...
} -
+
@Session.Title
-
- @if(Session.Status == SessionStatusEnum.NeedsPermission) - { - - - - Permission required - } - else if(Session.Status == SessionStatusEnum.NeedsUserInput) - { - - - - Input requested - } - else if(Session.Status == SessionStatusEnum.NeedsElicitation) - { - - - - Input requested - } - else if(Session.Status == SessionStatusEnum.Running) - { - - - - Agent running... - } - else - { - @TimeAgo - } -
+
-
\ No newline at end of file +
diff --git a/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor.cs b/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor.cs index 16c1f715..93b1a497 100644 --- a/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor.cs +++ b/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor.cs @@ -12,15 +12,6 @@ public partial class SessionListItem [Parameter] public EventCallback OnDelete { get; set; } [Parameter] public required string TimeAgo { get; set; } - static string GetSessionStatusClass(SessionModel session) => session.Status switch - { - SessionStatusEnum.NeedsPermission => "status-needs-permission", - SessionStatusEnum.NeedsUserInput => "status-needs-user-input", - SessionStatusEnum.NeedsElicitation => "status-needs-user-input", - SessionStatusEnum.Running => "status-running", - _ => "secondary-text" - }; - async Task HandleSelect() => await OnSelect.InvokeAsync(Session); async Task HandleDelete(MouseEventArgs e) => await OnDelete.InvokeAsync(e); -} \ No newline at end of file +} diff --git a/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor.css b/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor.css index 92cfecd3..6d5cc62e 100644 --- a/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor.css +++ b/src/Cockpit/Components/Pages/SessionsPanel/SessionListItem.razor.css @@ -10,19 +10,3 @@ .session-item.bg-app-active .session-delete-overlay { background: linear-gradient(to right, transparent, var(--active-color) 60%); } - -.status-needs-permission { - color: #FFA500; -} - -.status-needs-user-input { - color: #3b82f6; -} - -.status-running { - color: #FFB900; -} - -.status-finished { - color: #10893E; -} diff --git a/src/Cockpit/Features/ElicitationRequests/ElicitationFeature.cs b/src/Cockpit/Features/ElicitationRequests/ElicitationFeature.cs index ccef6fde..313b8828 100644 --- a/src/Cockpit/Features/ElicitationRequests/ElicitationFeature.cs +++ b/src/Cockpit/Features/ElicitationRequests/ElicitationFeature.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Cockpit.Features.Sessions; +using Cockpit.Features.Sessions.Interactions; using Cockpit.Features.Sessions.Models; using GitHub.Copilot; using GitHub.Copilot.Rpc; @@ -13,15 +14,20 @@ namespace Cockpit.Features.ElicitationRequests; public sealed partial class ElicitationFeature : IElicitationHandler, IElicitationEventSource { readonly ISessionStateProvider _sessionStateProvider; + readonly SessionInteractionCoordinator _interactionCoordinator; readonly ILogger _logger; readonly ConcurrentDictionary _pendingRequests = new(); public event Action? OnElicitationRequested; - public ElicitationFeature(ISessionStateProvider sessionStateProvider, ILogger logger) + public ElicitationFeature( + ISessionStateProvider sessionStateProvider, + ILogger logger, + SessionInteractionCoordinator? interactionCoordinator = null) { _sessionStateProvider = sessionStateProvider; + _interactionCoordinator = interactionCoordinator ?? new SessionInteractionCoordinator(sessionStateProvider); _logger = logger; } @@ -78,7 +84,7 @@ async Task RequestElicitationAsync(ElicitationRequestModel re try { - UpdateSessionOnElicitationRequested(request.SessionId, request); + _interactionCoordinator.AddElicitation(request.SessionId, request); try { @@ -103,64 +109,6 @@ async Task RequestElicitationAsync(ElicitationRequestModel re } } - void UpdateSessionOnElicitationRequested(string sessionId, ElicitationRequestModel request) - { - SessionModel? session = _sessionStateProvider.Sessions.FirstOrDefault(s => s.Id == sessionId); - if(session is null) - { - return; - } - - _logger.LogInformation("Elicitation requested — adding request {RequestId} to session {SessionId}", request.Id, sessionId); - - lock(session.StatusHistoryLock) - { - if(!session.PendingElicitationRequests.TryAdd(request.Id, request)) - { - _logger.LogWarning("Elicitation request {RequestId} already exists for session {SessionId}", request.Id, sessionId); - return; - } - - // Only push to history on the first blocking request across all three blocking types. - if(session.Status is not SessionStatusEnum.NeedsPermission - and not SessionStatusEnum.NeedsUserInput - and not SessionStatusEnum.NeedsElicitation) - { - session.StatusHistory.Push(session.Status); - } - - session.Status = SessionStatusEnum.NeedsElicitation; - } - - _sessionStateProvider.NotifyStateChanged(); - } - - void UpdateSessionOnElicitationResolved(string sessionId, string requestId) - { - SessionModel? session = _sessionStateProvider.Sessions.FirstOrDefault(s => s.Id == sessionId); - if(session is null) - { - return; - } - - _logger.LogInformation("Elicitation resolved — removing request {RequestId} from session {SessionId}", requestId, sessionId); - - lock(session.StatusHistoryLock) - { - session.PendingElicitationRequests.TryRemove(requestId, out _); - - session.Status = !session.PendingPermissionRequests.IsEmpty - ? SessionStatusEnum.NeedsPermission - : !session.PendingUserInputRequests.IsEmpty - ? SessionStatusEnum.NeedsUserInput - : !session.PendingElicitationRequests.IsEmpty - ? SessionStatusEnum.NeedsElicitation - : session.StatusHistory.TryPop(out SessionStatusEnum prev) ? prev : SessionStatusEnum.Idle; - } - - _sessionStateProvider.NotifyStateChanged(); - } - /// /// Resolves a pending elicitation request with the user's response. /// Pass to cancel (equivalent to Action = Cancel). @@ -186,7 +134,7 @@ public void ResolveElicitationRequest(string requestId, ElicitationResult? resul return; } - UpdateSessionOnElicitationResolved(request.SessionId, request.Id); + _interactionCoordinator.ResolveElicitation(request.SessionId, request.Id); } /// diff --git a/src/Cockpit/Features/Models/ModelFeature.Persistence.cs b/src/Cockpit/Features/Models/ModelFeature.Persistence.cs index 5631b9df..5c1c8726 100644 --- a/src/Cockpit/Features/Models/ModelFeature.Persistence.cs +++ b/src/Cockpit/Features/Models/ModelFeature.Persistence.cs @@ -91,7 +91,7 @@ public async Task TryRestoreModelSettings(SessionModel session) if(session.Model.Id != model.Id) { session.Model = model; - session.ModelChanged = true; + session.Lifecycle.ModelChanged = true; } if(modelSettings.TryGetValue("ByokConfigId", out string? byokConfigId) && !string.IsNullOrEmpty(byokConfigId)) @@ -101,7 +101,7 @@ public async Task TryRestoreModelSettings(SessionModel session) { session.ByokConfigId = byokConfigId; session.Model = byokConfig.ToModelInfo(); - session.ModelChanged = true; + session.Lifecycle.ModelChanged = true; } else { @@ -118,7 +118,7 @@ public async Task TryRestoreModelSettings(SessionModel session) if(!string.IsNullOrEmpty(session.ReasoningEffort)) { session.ReasoningEffort = null; - session.ModelChanged = true; + session.Lifecycle.ModelChanged = true; } return true; @@ -131,7 +131,7 @@ public async Task TryRestoreModelSettings(SessionModel session) && !model.SupportedReasoningEfforts.Contains(session.ReasoningEffort)) { session.ReasoningEffort = null; - session.ModelChanged = true; + session.Lifecycle.ModelChanged = true; } return true; @@ -140,7 +140,7 @@ public async Task TryRestoreModelSettings(SessionModel session) if(model.SupportedReasoningEfforts.Contains(reasoningEffort) && session.ReasoningEffort != reasoningEffort) { session.ReasoningEffort = reasoningEffort; - session.ModelChanged = true; + session.Lifecycle.ModelChanged = true; } else if(!model.SupportedReasoningEfforts.Contains(reasoningEffort)) { @@ -150,7 +150,7 @@ public async Task TryRestoreModelSettings(SessionModel session) && !model.SupportedReasoningEfforts.Contains(session.ReasoningEffort)) { session.ReasoningEffort = null; - session.ModelChanged = true; + session.Lifecycle.ModelChanged = true; } } diff --git a/src/Cockpit/Features/Permissions/PermissionFeature.cs b/src/Cockpit/Features/Permissions/PermissionFeature.cs index 82291ccc..ba780b83 100644 --- a/src/Cockpit/Features/Permissions/PermissionFeature.cs +++ b/src/Cockpit/Features/Permissions/PermissionFeature.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using Cockpit.Features.Permissions.Models; using Cockpit.Features.Sessions; +using Cockpit.Features.Sessions.Interactions; using Cockpit.Features.Sessions.Models; using GitHub.Copilot; using GitHub.Copilot.Rpc; @@ -17,6 +18,7 @@ public sealed partial class PermissionFeature : IPermissionHandler, IPermissionE readonly GlobalDenyFeature _globalDenyFeature; readonly SessionPermissionFeature _sessionPermissionFeature; readonly ISessionStateProvider _sessionStateProvider; + readonly SessionInteractionCoordinator _interactionCoordinator; readonly ILogger _logger; // In-memory cache of permissions @@ -31,12 +33,14 @@ public PermissionFeature( GlobalDenyFeature globalDenyFeature, SessionPermissionFeature sessionPermissionFeature, ISessionStateProvider sessionStateProvider, - ILogger logger) + ILogger logger, + SessionInteractionCoordinator? interactionCoordinator = null) { _globalPermissionFeature = globalPermissionFeature; _globalDenyFeature = globalDenyFeature; _sessionPermissionFeature = sessionPermissionFeature; _sessionStateProvider = sessionStateProvider; + _interactionCoordinator = interactionCoordinator ?? new SessionInteractionCoordinator(sessionStateProvider); _logger = logger; } @@ -56,7 +60,7 @@ public async Task HandlePermissionRequest(PermissionRequest _logger.LogInformation("Permission request: Kind={Kind}, Commands={Commands}, SessionId={SessionId}", request.Kind, string.Join(", ", permissionRequest.Commands), session.Id); // Check permission through our service - PermissionDecisionEnum decision = await CheckPermissionAsync(permissionRequest, session.IsYolo); + PermissionDecisionEnum decision = await CheckPermissionAsync(permissionRequest, session.Ui.IsYolo); _logger.LogInformation("Permission decision: {Decision} for {Commands}", decision, string.Join(", ", permissionRequest.Commands)); @@ -72,64 +76,6 @@ public async Task HandlePermissionRequest(PermissionRequest } } - void UpdateSessionOnPermissionResolved(string sessionId, string requestId) - { - SessionModel? session = _sessionStateProvider.Sessions.FirstOrDefault(s => s.Id == sessionId); - if(session is null) - { - return; - } - - _logger.LogInformation("Permission resolved - Removing request ID: {RequestId} from session {SessionId}", requestId, sessionId); - - lock(session.StatusHistoryLock) - { - session.PendingPermissionRequests.TryRemove(requestId, out _); - - session.Status = session.PendingPermissionRequests.IsEmpty && session.PendingUserInputRequests.IsEmpty && session.PendingElicitationRequests.IsEmpty - ? session.StatusHistory.TryPop(out SessionStatusEnum prev) ? prev : SessionStatusEnum.Idle - : session.PendingPermissionRequests.IsEmpty - ? !session.PendingUserInputRequests.IsEmpty - ? SessionStatusEnum.NeedsUserInput - : SessionStatusEnum.NeedsElicitation - : SessionStatusEnum.NeedsPermission; - } - - // Notify UI (outside lock to avoid potential deadlocks) - _sessionStateProvider.NotifyStateChanged(); - } - - void UpdateSessionOnPermissionRequested(string sessionId, PermissionRequestModel request) - { - SessionModel? session = _sessionStateProvider.Sessions.FirstOrDefault(s => s.Id == sessionId); - if(session is null) - { - return; - } - - _logger.LogInformation("Permission requested - Adding request ID: {RequestId} to session {SessionId}", request.Id, sessionId); - - lock(session.StatusHistoryLock) - { - if(!session.PendingPermissionRequests.TryAdd(request.Id, request)) - { - _logger.LogWarning("Permission request {RequestId} already exists for session {SessionId}", request.Id, sessionId); - return; - } - - // Only push to history on the first blocking request (i.e. when not already in a blocking status). - // Subsequent concurrent requests see NeedsPermission/NeedsUserInput and skip the push, preventing duplicates. - if(session.Status is not SessionStatusEnum.NeedsPermission and not SessionStatusEnum.NeedsUserInput and not SessionStatusEnum.NeedsElicitation) - { - session.StatusHistory.Push(session.Status); - } - session.Status = SessionStatusEnum.NeedsPermission; - } - - // Notify UI (outside lock to avoid potential deadlocks) - _sessionStateProvider.NotifyStateChanged(); - } - /// /// Check if a tool execution is permitted /// @@ -213,7 +159,7 @@ async Task RequestUserApprovalAsync(PermissionRequestMod // Store pending request using unique request ID _pendingRequests[request.Id] = request; - UpdateSessionOnPermissionRequested(request.SessionId, request); + _interactionCoordinator.AddPermission(request.SessionId, request); // Wait for user decision try @@ -226,7 +172,7 @@ async Task RequestUserApprovalAsync(PermissionRequestMod { _logger.LogError(ex, "Error waiting for permission decision"); // Clean up session state so the UI doesn't stay stuck in NeedsPermission - UpdateSessionOnPermissionResolved(request.SessionId, request.Id); + _interactionCoordinator.ResolvePermission(request.SessionId, request.Id); return PermissionDecisionEnum.Denied; } finally @@ -302,7 +248,7 @@ public void ResolvePermissionRequest(string requestId, PermissionDecisionEnum de _logger.LogDebug("TaskCompletionSource completed: {Completed}", completed); // Notify UI with requestId so it can be removed from session list - UpdateSessionOnPermissionResolved(request.SessionId, request.Id); + _interactionCoordinator.ResolvePermission(request.SessionId, request.Id); OnPermissionResolved?.Invoke(request.SessionId, request.Id); } @@ -358,7 +304,7 @@ void AutoResolveMatchingRequests(string sessionId, string excludeRequestId, Perm pendingRequest.CompletionSource.TrySetResult(decision); // Update session state and notify UI using the pending request's own session - UpdateSessionOnPermissionResolved(pendingRequest.SessionId, pendingRequest.Id); + _interactionCoordinator.ResolvePermission(pendingRequest.SessionId, pendingRequest.Id); OnPermissionResolved?.Invoke(pendingRequest.SessionId, pendingRequest.Id); } } diff --git a/src/Cockpit/Features/SessionEvents/Handlers/AssistantTurnStartHandler.cs b/src/Cockpit/Features/SessionEvents/Handlers/AssistantTurnStartHandler.cs index 0d7b64dd..0b6e490f 100644 --- a/src/Cockpit/Features/SessionEvents/Handlers/AssistantTurnStartHandler.cs +++ b/src/Cockpit/Features/SessionEvents/Handlers/AssistantTurnStartHandler.cs @@ -8,7 +8,7 @@ static class AssistantTurnStartHandler { internal static void Handle(SessionModel session, AssistantTurnStartEvent evt) { - session.Status = SessionStatusEnum.Running; + session.Lifecycle.AgentRunState = AgentRunStateEnum.Running; // A single user prompt can produce multiple assistant.turn_start events ("0", "1", ...). // Only consume a pending message at the first turn start for that prompt. diff --git a/src/Cockpit/Features/SessionEvents/Handlers/SessionErrorHandler.cs b/src/Cockpit/Features/SessionEvents/Handlers/SessionErrorHandler.cs index 9d3b3f87..7cdb5ebc 100644 --- a/src/Cockpit/Features/SessionEvents/Handlers/SessionErrorHandler.cs +++ b/src/Cockpit/Features/SessionEvents/Handlers/SessionErrorHandler.cs @@ -27,7 +27,7 @@ internal static void Handle(SessionModel session, SessionErrorEvent evt) }; session.Messages.Add(message); - session.Status = SessionStatusEnum.Error; + session.Lifecycle.AgentRunState = AgentRunStateEnum.Error; // Clear streaming state left over from the interrupted turn session.StreamingMessages.Clear(); @@ -47,7 +47,7 @@ internal static void HandleException(SessionModel session, Exception ex) }; session.Messages.Add(message); - session.Status = SessionStatusEnum.Error; + session.Lifecycle.AgentRunState = AgentRunStateEnum.Error; } static string SerializeExceptionEventJson(SessionModel session, Exception ex) diff --git a/src/Cockpit/Features/SessionEvents/Handlers/SessionIdleHandler.cs b/src/Cockpit/Features/SessionEvents/Handlers/SessionIdleHandler.cs index d55fa98b..27fe7abd 100644 --- a/src/Cockpit/Features/SessionEvents/Handlers/SessionIdleHandler.cs +++ b/src/Cockpit/Features/SessionEvents/Handlers/SessionIdleHandler.cs @@ -9,7 +9,8 @@ static class SessionIdleHandler { /// /// Raised when a session completes successfully (transitions to Idle after active work). - /// Not raised on error/abort, or when is set. + /// Not raised on error/abort, or when + /// is set. /// internal static event Action? OnSessionFinished; @@ -46,7 +47,7 @@ internal static void Handle(SessionModel session, DateTimeOffset eventTimestamp, { Debug.WriteLine($"Activity message already exists for group {group.Id}, skipping insertion"); session.ActiveWorkingGroup = null; - session.Status = SessionStatusEnum.Idle; + session.Lifecycle.AgentRunState = AgentRunStateEnum.Idle; return; } @@ -290,7 +291,7 @@ internal static void Handle(SessionModel session, DateTimeOffset eventTimestamp, if(keepRunning) { - session.Status = SessionStatusEnum.Running; + session.Lifecycle.AgentRunState = AgentRunStateEnum.Running; session.ActiveWorkingGroup = new ActivityGroupModel { StartTime = eventTimestamp.LocalDateTime, @@ -301,9 +302,9 @@ internal static void Handle(SessionModel session, DateTimeOffset eventTimestamp, } else { - session.Status = SessionStatusEnum.Idle; + session.Lifecycle.AgentRunState = AgentRunStateEnum.Idle; - if(groupStatus == GroupStatusEnum.Complete && !session.SuppressFinishedNotification) + if(groupStatus == GroupStatusEnum.Complete && !session.Lifecycle.SuppressFinishedNotification) { OnSessionFinished?.Invoke(); } diff --git a/src/Cockpit/Features/SessionEvents/Handlers/ThinkingExhaustedContinuationHandler.cs b/src/Cockpit/Features/SessionEvents/Handlers/ThinkingExhaustedContinuationHandler.cs index ccc5e8f4..5b2a9b40 100644 --- a/src/Cockpit/Features/SessionEvents/Handlers/ThinkingExhaustedContinuationHandler.cs +++ b/src/Cockpit/Features/SessionEvents/Handlers/ThinkingExhaustedContinuationHandler.cs @@ -23,6 +23,6 @@ internal static void Handle(SessionModel session, UserMessageEvent evt) TriggeredByUserMessageId = session.Messages.LastOrDefault(m => m.IsUser)?.Id }; - session.Status = SessionStatusEnum.Running; + session.Lifecycle.AgentRunState = AgentRunStateEnum.Running; } } diff --git a/src/Cockpit/Features/SessionEvents/Handlers/UserMessageHandler.cs b/src/Cockpit/Features/SessionEvents/Handlers/UserMessageHandler.cs index 2bed8ccb..f09beb8a 100644 --- a/src/Cockpit/Features/SessionEvents/Handlers/UserMessageHandler.cs +++ b/src/Cockpit/Features/SessionEvents/Handlers/UserMessageHandler.cs @@ -70,7 +70,7 @@ internal static void Handle(SessionModel session, UserMessageEvent evt) session.Messages.Add(message); } - session.Status = SessionStatusEnum.Running; + session.Lifecycle.AgentRunState = AgentRunStateEnum.Running; } static List? ConvertAttachments(Attachment[]? items) diff --git a/src/Cockpit/Features/SessionEvents/SessionEventProcessor.cs b/src/Cockpit/Features/SessionEvents/SessionEventProcessor.cs index 6b820f0a..99410bba 100644 --- a/src/Cockpit/Features/SessionEvents/SessionEventProcessor.cs +++ b/src/Cockpit/Features/SessionEvents/SessionEventProcessor.cs @@ -51,7 +51,7 @@ public void Process(SessionModel session, SessionEvent evt, Func +/// Session-scoped state for requests that are waiting on a user interaction. +/// Mutations are coordinated by . +/// +public sealed class PendingInteractionState +{ + readonly ConcurrentDictionary _permissions = new(); + readonly ConcurrentDictionary _userInputs = new(); + readonly ConcurrentDictionary _elicitations = new(); + + public IReadOnlyDictionary Permissions => _permissions; + public IReadOnlyDictionary UserInputs => _userInputs; + public IReadOnlyDictionary Elicitations => _elicitations; + + internal SessionStatusEnum? DisplayStatus { get; set; } + internal Lock SyncRoot { get; } = new(); + + internal bool TryAddPermission(PermissionRequestModel request) => _permissions.TryAdd(request.Id, request); + internal bool TryAddUserInput(UserInputRequestModel request) => _userInputs.TryAdd(request.Id, request); + internal bool TryAddElicitation(ElicitationRequestModel request) => _elicitations.TryAdd(request.Id, request); + + internal bool TryRemovePermission(string requestId) => _permissions.TryRemove(requestId, out _); + internal bool TryRemoveUserInput(string requestId) => _userInputs.TryRemove(requestId, out _); + internal bool TryRemoveElicitation(string requestId) => _elicitations.TryRemove(requestId, out _); + + internal void ClearPermissions() => _permissions.Clear(); + internal void ClearUserInputs() => _userInputs.Clear(); + internal void ClearElicitations() => _elicitations.Clear(); + + internal bool HasPermissions => !_permissions.IsEmpty; + internal bool HasUserInputs => !_userInputs.IsEmpty; + internal bool HasElicitations => !_elicitations.IsEmpty; +} diff --git a/src/Cockpit/Features/Sessions/Interactions/SessionInteractionCoordinator.cs b/src/Cockpit/Features/Sessions/Interactions/SessionInteractionCoordinator.cs new file mode 100644 index 00000000..fa9b6fcf --- /dev/null +++ b/src/Cockpit/Features/Sessions/Interactions/SessionInteractionCoordinator.cs @@ -0,0 +1,210 @@ +using Cockpit.Features.ElicitationRequests; +using Cockpit.Features.Permissions.Models; +using Cockpit.Features.Sessions.Models; +using Cockpit.Features.UserInputRequests; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cockpit.Features.Sessions.Interactions; + +[Flags] +public enum PendingInteractionKinds +{ + None = 0, + Permissions = 1, + UserInputs = 2, + Elicitations = 4, + All = Permissions | UserInputs | Elicitations +} + +/// +/// Owns session-visible pending-interaction bookkeeping. SDK-facing features retain ownership +/// of their completion sources, while all cross-type status and collection updates are +/// serialized here to preserve the existing display-status behaviour. +/// +public sealed class SessionInteractionCoordinator +{ + readonly ISessionStateProvider _sessionStateProvider; + readonly ILogger _logger; + + public SessionInteractionCoordinator( + ISessionStateProvider sessionStateProvider, + ILogger? logger = null) + { + _sessionStateProvider = sessionStateProvider; + _logger = logger ?? NullLogger.Instance; + } + + public void AddPermission(string sessionId, PermissionRequestModel request) => Add( + sessionId, + request.Id, + "permission", + SessionStatusEnum.NeedsPermission, + interactions => interactions.TryAddPermission(request)); + + public void AddUserInput(string sessionId, UserInputRequestModel request) => Add( + sessionId, + request.Id, + "user input", + SessionStatusEnum.NeedsUserInput, + interactions => interactions.TryAddUserInput(request)); + + public void AddElicitation(string sessionId, ElicitationRequestModel request) => Add( + sessionId, + request.Id, + "elicitation", + SessionStatusEnum.NeedsElicitation, + interactions => interactions.TryAddElicitation(request)); + + public void ResolvePermission(string sessionId, string requestId) => Resolve( + sessionId, + requestId, + "permission", + interactions => interactions.TryRemovePermission(requestId)); + + public void ResolveUserInput(string sessionId, string requestId) => Resolve( + sessionId, + requestId, + "user input", + interactions => interactions.TryRemoveUserInput(requestId)); + + public void ResolveElicitation(string sessionId, string requestId) => Resolve( + sessionId, + requestId, + "elicitation", + interactions => interactions.TryRemoveElicitation(requestId)); + + /// + /// Clears session-visible bookkeeping during lifecycle cleanup. The caller retains + /// responsibility for cancelling the SDK-facing completion sources and notifying the UI. + /// + public void ClearBookkeeping( + string sessionId, + PendingInteractionKinds interactionKinds) + { + SessionModel? session = FindSession(sessionId); + if(session is null) + { + return; + } + + PendingInteractionState interactions = session.PendingInteractions; + lock(interactions.SyncRoot) + { + if(interactionKinds.HasFlag(PendingInteractionKinds.Permissions)) + { + interactions.ClearPermissions(); + } + + if(interactionKinds.HasFlag(PendingInteractionKinds.UserInputs)) + { + interactions.ClearUserInputs(); + } + + if(interactionKinds.HasFlag(PendingInteractionKinds.Elicitations)) + { + interactions.ClearElicitations(); + } + + interactions.DisplayStatus = GetPendingInteractionStatus(interactions); + } + } + + void Add( + string sessionId, + string requestId, + string interactionType, + SessionStatusEnum blockingStatus, + Func tryAdd) + { + SessionModel? session = FindSession(sessionId); + if(session is null) + { + return; + } + + _logger.LogInformation( + "{InteractionType} requested - Adding request ID: {RequestId} to session {SessionId}", + interactionType, + requestId, + sessionId); + + PendingInteractionState interactions = session.PendingInteractions; + lock(interactions.SyncRoot) + { + if(!tryAdd(interactions)) + { + _logger.LogWarning( + "{InteractionType} request {RequestId} already exists for session {SessionId}", + interactionType, + requestId, + sessionId); + return; + } + + interactions.DisplayStatus = blockingStatus; + } + + _sessionStateProvider.NotifyStateChanged(); + } + + void Resolve( + string sessionId, + string requestId, + string interactionType, + Func tryRemove) + { + SessionModel? session = FindSession(sessionId); + if(session is null) + { + return; + } + + _logger.LogInformation( + "{InteractionType} resolved - Removing request ID: {RequestId} from session {SessionId}", + interactionType, + requestId, + sessionId); + + PendingInteractionState interactions = session.PendingInteractions; + lock(interactions.SyncRoot) + { + if(!tryRemove(interactions)) + { + _logger.LogDebug( + "{InteractionType} request {RequestId} is no longer pending for session {SessionId}", + interactionType, + requestId, + sessionId); + return; + } + + interactions.DisplayStatus = GetPendingInteractionStatus(interactions); + } + + _sessionStateProvider.NotifyStateChanged(); + } + + SessionModel? FindSession(string sessionId) + => _sessionStateProvider.Sessions.FirstOrDefault(session => session.Id == sessionId); + + static SessionStatusEnum? GetPendingInteractionStatus(PendingInteractionState interactions) + { + if(interactions.HasPermissions) + { + return SessionStatusEnum.NeedsPermission; + } + + if(interactions.HasUserInputs) + { + return SessionStatusEnum.NeedsUserInput; + } + + if(interactions.HasElicitations) + { + return SessionStatusEnum.NeedsElicitation; + } + + return null; + } +} diff --git a/src/Cockpit/Features/Sessions/Models/AgentRunStateEnum.cs b/src/Cockpit/Features/Sessions/Models/AgentRunStateEnum.cs new file mode 100644 index 00000000..ccb0a22a --- /dev/null +++ b/src/Cockpit/Features/Sessions/Models/AgentRunStateEnum.cs @@ -0,0 +1,11 @@ +namespace Cockpit.Features.Sessions.Models; + +/// +/// The agent's lifecycle state, independent of any pending interaction requests. +/// +public enum AgentRunStateEnum +{ + Idle, + Running, + Error +} diff --git a/src/Cockpit/Features/Sessions/Models/SessionConversationState.cs b/src/Cockpit/Features/Sessions/Models/SessionConversationState.cs new file mode 100644 index 00000000..b2b9e03c --- /dev/null +++ b/src/Cockpit/Features/Sessions/Models/SessionConversationState.cs @@ -0,0 +1,71 @@ +using System.Collections.Immutable; +using Cockpit.Features.SessionEvents.Models; + +namespace Cockpit.Features.Sessions.Models; + +/// +/// Mutable state produced while processing a session's conversation events. +/// All event-driven mutations are serialized through . +/// +public sealed class SessionConversationState +{ + List _messages = []; + + public List Messages => _messages; + + /// + /// Stable render view published after an event batch has finished mutating + /// . + /// + public ImmutableArray MessagesSnapshot { get; private set; } = []; + + /// + /// Publishes the current mutable message collection as an immutable render snapshot. + /// Existing callers may already hold ; the lock is re-entrant. + /// + internal void PublishMessagesSnapshot() + { + lock(SyncRoot) + { + MessagesSnapshot = [.. _messages]; + } + } + + /// + /// Replaces conversation history without retaining the caller's mutable collection. + /// + internal void ReplaceMessages(IEnumerable messages) + { + lock(SyncRoot) + { + _messages = [.. messages]; + MessagesSnapshot = [.. _messages]; + } + } + + /// + /// Clears mutable history and publishes an empty snapshot as one transition. + /// + internal void ClearMessages() + { + lock(SyncRoot) + { + _messages.Clear(); + _messages.TrimExcess(); + MessagesSnapshot = []; + } + } + + public ActivityGroupModel? ActiveWorkingGroup { get; set; } + public Dictionary StreamingMessages { get; } = []; + public Dictionary StreamingThinkingEvents { get; } = []; + + public int PendingMessageCount { get; set; } + public TokenUsageInfoModel? TokenUsageInfo { get; set; } + public bool IsCompacting { get; set; } + public bool AgentTurnCompleted { get; set; } + public bool HasQueuedImmediateMessage { get; set; } + public string? PendingTaskSummary { get; set; } + + public Lock SyncRoot { get; } = new(); +} diff --git a/src/Cockpit/Features/Sessions/Models/SessionLifecycleState.cs b/src/Cockpit/Features/Sessions/Models/SessionLifecycleState.cs new file mode 100644 index 00000000..7279253b --- /dev/null +++ b/src/Cockpit/Features/Sessions/Models/SessionLifecycleState.cs @@ -0,0 +1,20 @@ +namespace Cockpit.Features.Sessions.Models; + +/// +/// State that describes the SDK connection and agent lifecycle for a session. +/// Pending interactions and UI state must not overwrite these values. +/// +public sealed class SessionLifecycleState +{ + public AgentRunStateEnum AgentRunState { get; set; } = AgentRunStateEnum.Idle; + public SdkSessionStateEnum SdkState { get; set; } = SdkSessionStateEnum.NotLoaded; + + public bool ModelChanged { get; set; } + public bool AgentChanged { get; set; } + public bool AgentModeChanged { get; set; } + + /// + /// Prevents history replay from raising a session-finished notification. + /// + public bool SuppressFinishedNotification { get; set; } +} diff --git a/src/Cockpit/Features/Sessions/Models/SessionModel.ConversationCompatibility.cs b/src/Cockpit/Features/Sessions/Models/SessionModel.ConversationCompatibility.cs new file mode 100644 index 00000000..ccacdde2 --- /dev/null +++ b/src/Cockpit/Features/Sessions/Models/SessionModel.ConversationCompatibility.cs @@ -0,0 +1,61 @@ +using Cockpit.Features.SessionEvents.Models; + +namespace Cockpit.Features.Sessions.Models; + +/// +/// Compatibility surface for conversation consumers. These accessors forward to +/// and can be removed as consumers adopt explicit transitions. +/// +public partial class SessionModel +{ + public List Messages => Conversation.Messages; + + public IReadOnlyList MessagesSnapshot => Conversation.MessagesSnapshot; + + public ActivityGroupModel? ActiveWorkingGroup + { + get => Conversation.ActiveWorkingGroup; + set => Conversation.ActiveWorkingGroup = value; + } + + public Dictionary StreamingMessages => Conversation.StreamingMessages; + public Dictionary StreamingThinkingEvents => Conversation.StreamingThinkingEvents; + + public string? PendingTaskSummary + { + get => Conversation.PendingTaskSummary; + set => Conversation.PendingTaskSummary = value; + } + + public Lock SessionEventLock => Conversation.SyncRoot; + + public int PendingMessageCount + { + get => Conversation.PendingMessageCount; + set => Conversation.PendingMessageCount = value; + } + + public TokenUsageInfoModel? TokenUsageInfo + { + get => Conversation.TokenUsageInfo; + set => Conversation.TokenUsageInfo = value; + } + + public bool IsCompacting + { + get => Conversation.IsCompacting; + set => Conversation.IsCompacting = value; + } + + public bool AgentTurnCompleted + { + get => Conversation.AgentTurnCompleted; + set => Conversation.AgentTurnCompleted = value; + } + + public bool HasQueuedImmediateMessage + { + get => Conversation.HasQueuedImmediateMessage; + set => Conversation.HasQueuedImmediateMessage = value; + } +} diff --git a/src/Cockpit/Features/Sessions/Models/SessionModel.LifecycleCompatibility.cs b/src/Cockpit/Features/Sessions/Models/SessionModel.LifecycleCompatibility.cs new file mode 100644 index 00000000..d81e2d1e --- /dev/null +++ b/src/Cockpit/Features/Sessions/Models/SessionModel.LifecycleCompatibility.cs @@ -0,0 +1,44 @@ +namespace Cockpit.Features.Sessions.Models; + +/// +/// Compatibility surface for callers that have not yet adopted . +/// Lifecycle and event-processing code should access the grouped state directly. +/// +public partial class SessionModel +{ + public AgentRunStateEnum AgentRunState + { + get => Lifecycle.AgentRunState; + set => Lifecycle.AgentRunState = value; + } + + public SdkSessionStateEnum SdkState + { + get => Lifecycle.SdkState; + set => Lifecycle.SdkState = value; + } + + public bool ModelChanged + { + get => Lifecycle.ModelChanged; + set => Lifecycle.ModelChanged = value; + } + + public bool AgentChanged + { + get => Lifecycle.AgentChanged; + set => Lifecycle.AgentChanged = value; + } + + public bool AgentModeChanged + { + get => Lifecycle.AgentModeChanged; + set => Lifecycle.AgentModeChanged = value; + } + + public bool SuppressFinishedNotification + { + get => Lifecycle.SuppressFinishedNotification; + set => Lifecycle.SuppressFinishedNotification = value; + } +} diff --git a/src/Cockpit/Features/Sessions/Models/SessionModel.UiCompatibility.cs b/src/Cockpit/Features/Sessions/Models/SessionModel.UiCompatibility.cs new file mode 100644 index 00000000..7218bf92 --- /dev/null +++ b/src/Cockpit/Features/Sessions/Models/SessionModel.UiCompatibility.cs @@ -0,0 +1,40 @@ +namespace Cockpit.Features.Sessions.Models; + +/// +/// Compatibility surface for callers that have not yet adopted . +/// UI components should access the grouped state directly. +/// +public partial class SessionModel +{ + public bool IsYolo + { + get => Ui.IsYolo; + set => Ui.IsYolo = value; + } + + public bool IsTerminalOpen + { + get => Ui.IsTerminalOpen; + set => Ui.IsTerminalOpen = value; + } + + public string UserInput + { + get => Ui.DraftText; + set => Ui.DraftText = value; + } + + public List PendingAttachments + { + get => Ui.PendingAttachments; + set => Ui.PendingAttachments = value; + } + + public Lock PendingAttachmentsLock => Ui.PendingAttachmentsLock; + + public string UserInputResponseText + { + get => Ui.UserInputResponseText; + set => Ui.UserInputResponseText = value; + } +} diff --git a/src/Cockpit/Features/Sessions/Models/SessionModel.cs b/src/Cockpit/Features/Sessions/Models/SessionModel.cs index 9ea8baf8..99049382 100644 --- a/src/Cockpit/Features/Sessions/Models/SessionModel.cs +++ b/src/Cockpit/Features/Sessions/Models/SessionModel.cs @@ -1,36 +1,36 @@ -using System.Collections.Concurrent; -using Cockpit.Features.ElicitationRequests; -using Cockpit.Features.Permissions.Models; -using Cockpit.Features.SessionEvents.Models; +using Cockpit.Features.Sessions.Interactions; using GitHub.Copilot; -using Cockpit.Features.UserInputRequests; namespace Cockpit.Features.Sessions.Models; -public class SessionModel +public partial class SessionModel { public required string Id { get; set; } public required string Title { get; set; } public required DateTime CreatedAt { get; set; } public required DateTime LastActivity { get; set; } - public SessionStatusEnum Status { get; set; } = SessionStatusEnum.Idle; - List _messages = []; - public List Messages + /// + /// UI-facing status. The interaction coordinator controls the pending-interaction + /// overlay while continues to track the agent lifecycle. + /// + public SessionStatusEnum DisplayStatus => PendingInteractions.DisplayStatus ?? Lifecycle.AgentRunState switch { - get => _messages; - set - { - _messages = value; - MessagesSnapshot = [.. value]; - } - } + AgentRunStateEnum.Running => SessionStatusEnum.Running, + AgentRunStateEnum.Error => SessionStatusEnum.Error, + _ => SessionStatusEnum.Idle + }; /// - /// A snapshot of taken inside after each event is processed, - /// or whenever is replaced. The Blazor renderer reads this instead of - /// directly to avoid concurrent-modification exceptions. + /// Compatibility alias for consumers that only read session status. + /// New code should use for lifecycle decisions and + /// for presentation. /// - public IReadOnlyList MessagesSnapshot { get; internal set; } = []; + public SessionStatusEnum Status => DisplayStatus; + + public PendingInteractionState PendingInteractions { get; } = new(); + public SessionConversationState Conversation { get; } = new(); + public SessionUiState Ui { get; } = new(); + public SessionLifecycleState Lifecycle { get; } = new(); public required SessionContext Context { get; set; } public required ModelInfo Model { get; set; } public string? ReasoningEffort { get; set; } @@ -40,127 +40,4 @@ public List Messages /// Null for built-in Copilot models. /// public string? ByokConfigId { get; set; } - public ActivityGroupModel? ActiveWorkingGroup { get; set; } - public Dictionary StreamingMessages { get; } = []; - - /// - /// Live-streaming instances keyed by message ID. - /// Created by AssistantMessageDeltaHandler so subsequent deltas update the same - /// thinking-panel entry rather than creating duplicates, and cleaned up by - /// AssistantMessageHandler when the complete message arrives. - /// - public Dictionary StreamingThinkingEvents { get; } = []; - - /// - /// Pending permission requests for this session (supports multiple concurrent requests) - /// Key: request.Id, Value: PermissionRequest - /// - public ConcurrentDictionary PendingPermissionRequests { get; set; } = new(); - - /// - /// Pending user input requests for this session (supports multiple concurrent requests) - /// Key: request.Id, Value: UserInputRequestModel - /// - public ConcurrentDictionary PendingUserInputRequests { get; set; } = new(); - - /// - /// Pending elicitation requests for this session (supports multiple concurrent requests) - /// Key: request.Id, Value: ElicitationRequestModel - /// - public ConcurrentDictionary PendingElicitationRequests { get; set; } = new(); - - /// - /// History of statuses before blocking requests (permission/user-input). - /// Pushed when the first blocking request of a type arrives; popped when all of that type resolve. - /// - public Stack StatusHistory { get; } = new(); - public readonly Lock StatusHistoryLock = new(); - - /// - /// Tracks the SDK connection lifecycle of this session. - /// - public SdkSessionStateEnum SdkState { get; set; } = SdkSessionStateEnum.NotLoaded; - public bool ModelChanged { get; set; } - public bool AgentChanged { get; set; } - public bool AgentModeChanged { get; set; } - - /// - /// When the session.idle handler will not raise - /// . - /// Set during session-history replay to avoid spurious completion notifications. - /// - public bool SuppressFinishedNotification { get; set; } - - /// - /// Set by SessionTaskCompleteHandler when the SDK emits a session.task_complete - /// event. Consumed and cleared by SessionIdleHandler as the preferred summary source, - /// with the heuristic last-message extraction kept as a fallback. - /// - public string? PendingTaskSummary { get; set; } - public bool IsYolo { get; set; } - public bool IsTerminalOpen { get; set; } - - /// - /// Per-session draft text preserved across session switches. - /// - public string UserInput { get; set; } = string.Empty; - - /// - /// Per-session pending attachments preserved across session switches. - /// - public List PendingAttachments { get; set; } = []; - - /// - /// Synchronizes mutations to across threads (e.g. JS-interop paste callbacks vs. UI-thread picks/sends). - /// - public readonly Lock PendingAttachmentsLock = new(); - - /// - /// Per-session user input response text preserved across session switches. - /// - public string UserInputResponseText { get; set; } = string.Empty; - - /// - /// Synchronizes live session event/message mutations to preserve ordering. - /// - public readonly Lock SessionEventLock = new(); - - /// - /// Number of messages queued while the agent is busy (enqueue mode). - /// Updated by PendingMessagesModifiedEvent processing. - /// - public int PendingMessageCount { get; set; } - - /// - /// Latest token usage info received from session.usage_info events. - /// until the first usage event is received. - /// - public TokenUsageInfoModel? TokenUsageInfo { get; set; } - - /// - /// while a context compaction operation is in progress. - /// Set to on session.compaction_start and cleared on - /// session.compaction_complete. - /// - public bool IsCompacting { get; set; } - - /// - /// Set to by the AssistantTurnEndEvent case in - /// when the agent finishes its current - /// mini-turn. Cleared to when a new AssistantTurnStartEvent - /// fires (meaning the agent has more work to do). - /// Consumed and reset by the safety-net (user.message) to decide whether to promote the - /// final summary out of the ops group: means the agent's last - /// turn completed cleanly; means it was mid-turn (interrupted). - /// - public bool AgentTurnCompleted { get; set; } - - /// - /// Set to by SessionFeature.SendMessageAsync when the user - /// sends a message in immediate (steering) mode while the agent is already processing a - /// turn. Consumed and cleared by SessionIdleHandler when it finalizes the prior - /// turn, to keep the working panel open and suppress the completion sound during the - /// brief gap before the next turn starts. - /// - public bool HasQueuedImmediateMessage { get; set; } -} \ No newline at end of file +} diff --git a/src/Cockpit/Features/Sessions/Models/SessionStatusEnum.cs b/src/Cockpit/Features/Sessions/Models/SessionStatusEnum.cs index 1ddc6cb3..ee9c0c6d 100644 --- a/src/Cockpit/Features/Sessions/Models/SessionStatusEnum.cs +++ b/src/Cockpit/Features/Sessions/Models/SessionStatusEnum.cs @@ -1,5 +1,8 @@ namespace Cockpit.Features.Sessions.Models; +/// +/// UI-facing session status projected from the agent run state and pending interactions. +/// public enum SessionStatusEnum { Idle, @@ -8,4 +11,4 @@ public enum SessionStatusEnum NeedsUserInput, NeedsElicitation, Error, -} \ No newline at end of file +} diff --git a/src/Cockpit/Features/Sessions/Models/SessionUiState.cs b/src/Cockpit/Features/Sessions/Models/SessionUiState.cs new file mode 100644 index 00000000..b90ff4d5 --- /dev/null +++ b/src/Cockpit/Features/Sessions/Models/SessionUiState.cs @@ -0,0 +1,31 @@ +namespace Cockpit.Features.Sessions.Models; + +/// +/// Transient state owned by the session UI. This state is not part of the SDK +/// lifecycle or the conversation produced by session events. +/// +public sealed class SessionUiState +{ + public bool IsYolo { get; set; } + public bool IsTerminalOpen { get; set; } + + /// + /// Draft text preserved when the user switches sessions. + /// + public string DraftText { get; set; } = string.Empty; + + /// + /// Attachments staged in the composer and preserved across session switches. + /// + public List PendingAttachments { get; set; } = []; + + /// + /// Synchronizes attachment mutations made by UI callbacks on different threads. + /// + public Lock PendingAttachmentsLock { get; } = new(); + + /// + /// Draft response for the active user-input request. + /// + public string UserInputResponseText { get; set; } = string.Empty; +} diff --git a/src/Cockpit/Features/Sessions/SessionFeature.Creation.cs b/src/Cockpit/Features/Sessions/SessionFeature.Creation.cs new file mode 100644 index 00000000..753bf49c --- /dev/null +++ b/src/Cockpit/Features/Sessions/SessionFeature.Creation.cs @@ -0,0 +1,196 @@ +using System.Text.Json; +using Cockpit.Features.Agents.Models; +using Cockpit.Features.Canvas; +using Cockpit.Features.Git.Models; +using Cockpit.Features.Permissions; +using Cockpit.Features.SessionEvents; +using Cockpit.Features.SessionEvents.Models; +using Cockpit.Features.Sessions.Interactions; +using Cockpit.Features.Sessions.Models; +using Cockpit.Features.SystemMessage; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Microsoft.Extensions.Logging; +using SdkPlugin = GitHub.Copilot.Rpc.Plugin; +using SdkSessionMetadata = GitHub.Copilot.SessionMetadata; + +namespace Cockpit.Features.Sessions; + +public sealed partial class SessionFeature +{ + public async Task CreateSession(string? workingDirectory, CancellationToken cancellationToken = default) + { + CopilotClient? client = null; + CopilotSession? sdkSession = null; + bool sdkSessionRegistered = false; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + ModelInfo defaultModel = await _modelFeature.GetDefaultModel(cancellationToken); + GitHub.Copilot.ProviderConfig? providerConfig = await _modelFeature.GetProviderConfig(defaultModel.Id, cancellationToken); + + // GetContext spawns git subprocesses, and its result isn't needed until the SessionModel + // is built (further below). Kick it off here so it overlaps the SDK CreateSessionAsync + // round-trip instead of blocking before it. + Task gitContextTask = _gitFeature.GetContext(workingDirectory); + + // BYOK providers don't support Copilot-specific reasoning effort; always pass null for them. + string? effectiveReasoningEffort = providerConfig is null ? defaultModel.DefaultReasoningEffort : null; + + SessionConfig config = new() + { + ClientName = "Cockpit", + Model = defaultModel.Id, + ReasoningEffort = effectiveReasoningEffort, + Streaming = true, + InfiniteSessions = new InfiniteSessionConfig + { + Enabled = true + }, + WorkingDirectory = workingDirectory, + OnPermissionRequest = _permissionHandler.HandlePermissionRequest, + OnUserInputRequest = _userInputHandler.HandleUserInputRequest, + OnElicitationRequest = _elicitationHandler.HandleElicitationRequest, + Hooks = _hooksFactory.CreateHooks(defaultModel.Id, effectiveReasoningEffort, workingDirectory), + EnableConfigDiscovery = true, + Provider = providerConfig + }; + + if(_appSettingsFeature.CanvasEnabled) + { + config.RequestCanvasRenderer = true; + config.RequestExtensions = true; + config.ExtensionInfo = new ExtensionInfo { Source = "cockpit", Name = "canvas-provider" }; + config.Canvases = + [ + CreateCockpitCanvasDeclaration() + ]; + config.CanvasHandler = new SessionCanvasHandler(_canvasWindowManager); + } + + ApplySystemMessageCustomization(config, defaultModel); + + cancellationToken.ThrowIfCancellationRequested(); + + client = await _clientFeature.GetClientAsync(cancellationToken); + CopilotSession createdSession = await client.CreateSessionAsync(config, cancellationToken); + sdkSession = createdSession; + + if(cancellationToken.IsCancellationRequested) + { + await client.DeleteSessionAsync(createdSession.SessionId, CancellationToken.None); + await createdSession.DisposeAsync(); + sdkSession = null; + cancellationToken.ThrowIfCancellationRequested(); + } + + _sdkRegistry.Register(createdSession, evt => + { + _logger.LogDebug("Session {SessionId} event: {EventType}", createdSession.SessionId, evt.Type); + HandleSessionEvent(createdSession.SessionId, evt); + }); + sdkSessionRegistered = true; + + // git context was started before the SDK round-trip; collect it now that it's needed. + GitContext? gitContext = await gitContextTask; + + SessionModel chatSession = new() + { + Id = createdSession.SessionId, + Title = "New Session", + CreatedAt = DateTime.UtcNow, + LastActivity = DateTime.UtcNow, + AgentRunState = AgentRunStateEnum.Idle, + Context = new() + { + CurrentWorkingDirectory = workingDirectory, + WorkspacePath = createdSession.WorkspacePath, + GitRoot = gitContext?.GitRoot, + Repository = gitContext?.Repository, + Branch = gitContext?.Branch + }, + Model = defaultModel, + ReasoningEffort = defaultModel.DefaultReasoningEffort, + SdkState = SdkSessionStateEnum.Resumed + }; + + SessionWorkingDirectoryNormalizer.ApplyContextConsistency(chatSession.Context); + + cancellationToken.ThrowIfCancellationRequested(); + + await LoadContextPanelDataAsync(chatSession, createdSession); + + cancellationToken.ThrowIfCancellationRequested(); + + _sdkSessionByokId[chatSession.Id] = chatSession.ByokConfigId; + + _sessionListFeature.AddSession(chatSession); + + // These three writes are best-effort metadata used only to *resume* the session later + // (saved model id, agent, agent-mode). They have no bearing on the SessionModel returned + // to the UI or on SwitchCurrentSessionAsync below, so there's no reason to make the user + // wait on disk I/O — persist them in the background and just log any failure. + _ = PersistSessionMetadataInBackground(chatSession); + + await SwitchCurrentSessionAsync(chatSession); + + return chatSession; + } + catch(OperationCanceledException) + { + if(client is not null && sdkSession is not null) + { + string sessionId = sdkSession.SessionId; + try + { + await client.DeleteSessionAsync(sessionId, CancellationToken.None); + + if(sdkSessionRegistered) + { + _sdkRegistry.Remove(sessionId); + } + + await sdkSession.DisposeAsync(); + sdkSession = null; + } + catch(Exception cleanupEx) + { + _logger.LogWarning(cleanupEx, "Failed to cleanup canceled session {SessionId}", sessionId); + } + } + + throw; + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to create new session"); + throw; + } + } + + /// + /// Persists the best-effort resume metadata (model, agent, agent-mode) for a freshly created + /// session off the critical path. The three writes target independent files, so they run + /// concurrently; the whole operation is offloaded to the thread pool so session creation never + /// blocks on disk I/O. Each writer already swallows its own failures, but any unexpected fault + /// is logged here rather than left unobserved. + /// + Task PersistSessionMetadataInBackground(SessionModel chatSession) + => Task.Run(async () => + { + try + { + await Task.WhenAll( + _modelFeature.SaveSessionModel(chatSession), + _agentPersistence.SaveSessionAgent(chatSession), + _sessionModePersistence.SaveSessionMode(chatSession, CancellationToken.None)); + } + catch(Exception ex) + { + _logger.LogWarning(ex, "Background persistence failed for new session {SessionId}", chatSession.Id); + } + }); + +} diff --git a/src/Cockpit/Features/Sessions/SessionFeature.Discovery.cs b/src/Cockpit/Features/Sessions/SessionFeature.Discovery.cs new file mode 100644 index 00000000..6400ea24 --- /dev/null +++ b/src/Cockpit/Features/Sessions/SessionFeature.Discovery.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using Cockpit.Features.Agents.Models; +using Cockpit.Features.Canvas; +using Cockpit.Features.Git.Models; +using Cockpit.Features.Permissions; +using Cockpit.Features.SessionEvents; +using Cockpit.Features.SessionEvents.Models; +using Cockpit.Features.Sessions.Interactions; +using Cockpit.Features.Sessions.Models; +using Cockpit.Features.SystemMessage; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Microsoft.Extensions.Logging; +using SdkPlugin = GitHub.Copilot.Rpc.Plugin; +using SdkSessionMetadata = GitHub.Copilot.SessionMetadata; + +namespace Cockpit.Features.Sessions; + +public sealed partial class SessionFeature +{ + Task? _loadExistingSessionsTask; + readonly Lock _loadGate = new(); + + public Task LoadExistingSessions() + { + lock(_loadGate) + { + if(_loadExistingSessionsTask is null || _loadExistingSessionsTask.IsCanceled || _loadExistingSessionsTask.IsFaulted) + { + _loadExistingSessionsTask = RefreshExistingSessions(); + } + + return _loadExistingSessionsTask; + } + } + + public async Task RefreshExistingSessions() + { + try + { + _logger.LogInformation("Loading existing sessions from SDK..."); + + CopilotClient client = await _clientFeature.GetClientAsync(); + + Task defaultModelTask = _modelFeature.GetDefaultModel().AsTask(); + + IList sessionMetadataList; + try + { + sessionMetadataList = await client.ListSessionsAsync(); + } + catch + { + // Ensure any failure from the overlapped model fetch is observed on the error path. + try { await defaultModelTask; } catch { /* ignore */ } + throw; + } + + ModelInfo defaultModel = await defaultModelTask; + if(sessionMetadataList.Count == 0) + { + _logger.LogInformation("No existing sessions found"); + return; + } + + _logger.LogInformation("Found {Count} existing sessions", sessionMetadataList.Count); + + PopulateSessionsFromMetadata(sessionMetadataList, defaultModel, _sessionListFeature, _logger); + + _sessionListFeature.NotifyStateChanged(); + _logger.LogInformation("Successfully loaded {Count} sessions", _sessionListFeature.Sessions.Count); + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to load existing sessions"); + } + } + + /// + /// Materializes into instances and + /// adds the not-yet-known ones to . Extracted as an + /// method (with no SDK/network dependencies) so it can be unit + /// tested and benchmarked directly. Sessions already present (matched by id) are skipped. + /// + internal static void PopulateSessionsFromMetadata( + IList sessionMetadataList, + ModelInfo defaultModel, + SessionListFeature sessionListFeature, + ILogger logger) + { + IReadOnlyList existing = sessionListFeature.Sessions; + HashSet seenSessionIds = new(existing.Count + sessionMetadataList.Count, StringComparer.Ordinal); + foreach(SessionModel session in existing) + { + seenSessionIds.Add(session.Id); + } + + List newSessions = new(sessionMetadataList.Count); + SessionWorkingDirectoryNormalizer.LaunchDirectories launchDirectories = SessionWorkingDirectoryNormalizer.LaunchDirectories.Capture(); + foreach(SdkSessionMetadata metadata in sessionMetadataList) + { + // Add returns false when the id is already known (existing session or duplicate + // in the incoming batch), mirroring the original per-item membership check. + if(!seenSessionIds.Add(metadata.SessionId)) + { + continue; + } + + try + { + newSessions.Add(CreateExistingSessionModel(metadata, defaultModel, launchDirectories)); + } + catch(Exception ex) + { + // Remove the id from seenSessionIds on failure to restore the original retry-on-failure + // behavior: a duplicate entry later in this batch will be attempted again rather than + // skipped, and the session won't be permanently marked as seen. + seenSessionIds.Remove(metadata.SessionId); + logger.LogWarning(ex, "Failed to load session {SessionId}", metadata.SessionId); + } + } + + sessionListFeature.AddSessionsAtFront(newSessions); + + if(logger.IsEnabled(LogLevel.Information)) + { + foreach(SessionModel session in newSessions) + { + logger.LogInformation("Loaded session {SessionId}", session.Id); + } + } + } + + static SessionModel CreateExistingSessionModel(SdkSessionMetadata metadata, ModelInfo defaultModel, in SessionWorkingDirectoryNormalizer.LaunchDirectories launchDirectories) + { + // Normalize once against pre-captured launch directories. The original code additionally + // called ApplyContextConsistency, which re-normalized (idempotent) and nulled the Git + // fields when the cwd was null — both effects are already produced by the single Normalize + // call and the conditional assignments below, so the redundant second normalization is + // dropped. + string? cwd = SessionWorkingDirectoryNormalizer.Normalize(metadata.Context?.WorkingDirectory, launchDirectories); + + return new SessionModel + { + Id = metadata.SessionId, + Title = metadata.Summary ?? $"Session {metadata.SessionId[..8]}", + CreatedAt = metadata.StartTime.UtcDateTime, + LastActivity = metadata.ModifiedTime.UtcDateTime, + AgentRunState = AgentRunStateEnum.Idle, + Model = defaultModel, + ReasoningEffort = defaultModel.DefaultReasoningEffort, + Context = new() + { + CurrentWorkingDirectory = cwd, + WorkspacePath = null, + GitRoot = cwd is null ? null : metadata.Context?.GitRoot, + Repository = cwd is null ? null : metadata.Context?.Repository, + Branch = cwd is null ? null : metadata.Context?.Branch + } + }; + } + +} diff --git a/src/Cockpit/Features/Sessions/SessionFeature.Eviction.cs b/src/Cockpit/Features/Sessions/SessionFeature.Eviction.cs index 1e32edc0..7f54d941 100644 --- a/src/Cockpit/Features/Sessions/SessionFeature.Eviction.cs +++ b/src/Cockpit/Features/Sessions/SessionFeature.Eviction.cs @@ -1,4 +1,5 @@ using Cockpit.Features.Canvas; +using Cockpit.Features.Sessions.Interactions; using Cockpit.Features.Sessions.Models; using GitHub.Copilot; using Microsoft.Extensions.Logging; @@ -71,12 +72,12 @@ bool IsEvictionCandidate(SessionModel session) return false; } - if(session.SdkState is not (SdkSessionStateEnum.Loaded or SdkSessionStateEnum.Resumed)) + if(session.Lifecycle.SdkState is not (SdkSessionStateEnum.Loaded or SdkSessionStateEnum.Resumed)) { return false; } - if(session.Status is not SessionStatusEnum.Idle) + if(session.Lifecycle.AgentRunState is not AgentRunStateEnum.Idle) { return false; } @@ -112,10 +113,10 @@ async Task UnloadSessionAsync(SessionModel session, CancellationToken ct) // Transition to NotLoaded first so LoadSession performs a full reload instead of // fast-pathing to a now-empty session if called concurrently. - session.SdkState = SdkSessionStateEnum.NotLoaded; + session.Lifecycle.SdkState = SdkSessionStateEnum.NotLoaded; // Clear message history - session.Messages = []; // setter also clears MessagesSnapshot + session.Conversation.ClearMessages(); session.ActiveWorkingGroup = null; session.StreamingMessages.Clear(); session.StreamingThinkingEvents.Clear(); @@ -124,9 +125,9 @@ async Task UnloadSessionAsync(SessionModel session, CancellationToken ct) session.PendingTaskSummary = null; // Clear pending model/agent change flags so reload doesn't make redundant SDK calls - session.ModelChanged = false; - session.AgentChanged = false; - session.AgentModeChanged = false; + session.Lifecycle.ModelChanged = false; + session.Lifecycle.AgentChanged = false; + session.Lifecycle.AgentModeChanged = false; // Clear context panel data (populated by LoadContextPanelDataAsync) session.Context.Agents = []; @@ -151,13 +152,9 @@ async Task UnloadSessionAsync(SessionModel session, CancellationToken ct) session.Context.SessionPermissionCommands = []; } - lock(session.StatusHistoryLock) - { - session.StatusHistory.Clear(); - } - - session.PendingPermissionRequests.Clear(); - session.PendingUserInputRequests.Clear(); + _interactionCoordinator.ClearBookkeeping( + session.Id, + PendingInteractionKinds.Permissions | PendingInteractionKinds.UserInputs); // Cancel any awaiting TCS-based handlers so SDK threads don't hang indefinitely. _permissionHandler.CancelPendingRequestsForSession(session.Id); diff --git a/src/Cockpit/Features/Sessions/SessionFeature.Lifecycle.cs b/src/Cockpit/Features/Sessions/SessionFeature.Lifecycle.cs index 7d8a4712..6025e54f 100644 --- a/src/Cockpit/Features/Sessions/SessionFeature.Lifecycle.cs +++ b/src/Cockpit/Features/Sessions/SessionFeature.Lifecycle.cs @@ -5,6 +5,7 @@ using Cockpit.Features.Permissions; using Cockpit.Features.SessionEvents; using Cockpit.Features.SessionEvents.Models; +using Cockpit.Features.Sessions.Interactions; using Cockpit.Features.Sessions.Models; using Cockpit.Features.SystemMessage; using GitHub.Copilot; @@ -17,550 +18,6 @@ namespace Cockpit.Features.Sessions; public sealed partial class SessionFeature { - Task? _loadExistingSessionsTask; - readonly Lock _loadGate = new(); - - public Task LoadExistingSessions() - { - lock(_loadGate) - { - if(_loadExistingSessionsTask is null || _loadExistingSessionsTask.IsCanceled || _loadExistingSessionsTask.IsFaulted) - { - _loadExistingSessionsTask = RefreshExistingSessions(); - } - - return _loadExistingSessionsTask; - } - } - - public async Task RefreshExistingSessions() - { - try - { - _logger.LogInformation("Loading existing sessions from SDK..."); - - CopilotClient client = await _clientFeature.GetClientAsync(); - - Task defaultModelTask = _modelFeature.GetDefaultModel().AsTask(); - - IList sessionMetadataList; - try - { - sessionMetadataList = await client.ListSessionsAsync(); - } - catch - { - // Ensure any failure from the overlapped model fetch is observed on the error path. - try { await defaultModelTask; } catch { /* ignore */ } - throw; - } - - ModelInfo defaultModel = await defaultModelTask; - if(sessionMetadataList.Count == 0) - { - _logger.LogInformation("No existing sessions found"); - return; - } - - _logger.LogInformation("Found {Count} existing sessions", sessionMetadataList.Count); - - PopulateSessionsFromMetadata(sessionMetadataList, defaultModel, _sessionListFeature, _logger); - - _sessionListFeature.NotifyStateChanged(); - _logger.LogInformation("Successfully loaded {Count} sessions", _sessionListFeature.Sessions.Count); - } - catch(Exception ex) - { - _logger.LogError(ex, "Failed to load existing sessions"); - } - } - - /// - /// Materializes into instances and - /// adds the not-yet-known ones to . Extracted as an - /// method (with no SDK/network dependencies) so it can be unit - /// tested and benchmarked directly. Sessions already present (matched by id) are skipped. - /// - internal static void PopulateSessionsFromMetadata( - IList sessionMetadataList, - ModelInfo defaultModel, - SessionListFeature sessionListFeature, - ILogger logger) - { - IReadOnlyList existing = sessionListFeature.Sessions; - HashSet seenSessionIds = new(existing.Count + sessionMetadataList.Count, StringComparer.Ordinal); - foreach(SessionModel session in existing) - { - seenSessionIds.Add(session.Id); - } - - List newSessions = new(sessionMetadataList.Count); - SessionWorkingDirectoryNormalizer.LaunchDirectories launchDirectories = SessionWorkingDirectoryNormalizer.LaunchDirectories.Capture(); - foreach(SdkSessionMetadata metadata in sessionMetadataList) - { - // Add returns false when the id is already known (existing session or duplicate - // in the incoming batch), mirroring the original per-item membership check. - if(!seenSessionIds.Add(metadata.SessionId)) - { - continue; - } - - try - { - newSessions.Add(CreateExistingSessionModel(metadata, defaultModel, launchDirectories)); - } - catch(Exception ex) - { - // Remove the id from seenSessionIds on failure to restore the original retry-on-failure - // behavior: a duplicate entry later in this batch will be attempted again rather than - // skipped, and the session won't be permanently marked as seen. - seenSessionIds.Remove(metadata.SessionId); - logger.LogWarning(ex, "Failed to load session {SessionId}", metadata.SessionId); - } - } - - sessionListFeature.AddSessionsAtFront(newSessions); - - if(logger.IsEnabled(LogLevel.Information)) - { - foreach(SessionModel session in newSessions) - { - logger.LogInformation("Loaded session {SessionId}", session.Id); - } - } - } - - static SessionModel CreateExistingSessionModel(SdkSessionMetadata metadata, ModelInfo defaultModel, in SessionWorkingDirectoryNormalizer.LaunchDirectories launchDirectories) - { - // Normalize once against pre-captured launch directories. The original code additionally - // called ApplyContextConsistency, which re-normalized (idempotent) and nulled the Git - // fields when the cwd was null — both effects are already produced by the single Normalize - // call and the conditional assignments below, so the redundant second normalization is - // dropped. - string? cwd = SessionWorkingDirectoryNormalizer.Normalize(metadata.Context?.WorkingDirectory, launchDirectories); - - return new SessionModel - { - Id = metadata.SessionId, - Title = metadata.Summary ?? $"Session {metadata.SessionId[..8]}", - CreatedAt = metadata.StartTime.UtcDateTime, - LastActivity = metadata.ModifiedTime.UtcDateTime, - Status = SessionStatusEnum.Idle, - Model = defaultModel, - ReasoningEffort = defaultModel.DefaultReasoningEffort, - Context = new() - { - CurrentWorkingDirectory = cwd, - WorkspacePath = null, - GitRoot = cwd is null ? null : metadata.Context?.GitRoot, - Repository = cwd is null ? null : metadata.Context?.Repository, - Branch = cwd is null ? null : metadata.Context?.Branch - } - }; - } - - public async Task CreateSession(string? workingDirectory, CancellationToken cancellationToken = default) - { - CopilotClient? client = null; - CopilotSession? sdkSession = null; - bool sdkSessionRegistered = false; - - try - { - cancellationToken.ThrowIfCancellationRequested(); - - ModelInfo defaultModel = await _modelFeature.GetDefaultModel(cancellationToken); - GitHub.Copilot.ProviderConfig? providerConfig = await _modelFeature.GetProviderConfig(defaultModel.Id, cancellationToken); - - // GetContext spawns git subprocesses, and its result isn't needed until the SessionModel - // is built (further below). Kick it off here so it overlaps the SDK CreateSessionAsync - // round-trip instead of blocking before it. - Task gitContextTask = _gitFeature.GetContext(workingDirectory); - - // BYOK providers don't support Copilot-specific reasoning effort; always pass null for them. - string? effectiveReasoningEffort = providerConfig is null ? defaultModel.DefaultReasoningEffort : null; - - SessionConfig config = new() - { - ClientName = "Cockpit", - Model = defaultModel.Id, - ReasoningEffort = effectiveReasoningEffort, - Streaming = true, - InfiniteSessions = new InfiniteSessionConfig - { - Enabled = true - }, - WorkingDirectory = workingDirectory, - OnPermissionRequest = _permissionHandler.HandlePermissionRequest, - OnUserInputRequest = _userInputHandler.HandleUserInputRequest, - OnElicitationRequest = _elicitationHandler.HandleElicitationRequest, - Hooks = _hooksFactory.CreateHooks(defaultModel.Id, effectiveReasoningEffort, workingDirectory), - EnableConfigDiscovery = true, - Provider = providerConfig - }; - - if(_appSettingsFeature.CanvasEnabled) - { - config.RequestCanvasRenderer = true; - config.RequestExtensions = true; - config.ExtensionInfo = new ExtensionInfo { Source = "cockpit", Name = "canvas-provider" }; - config.Canvases = - [ - CreateCockpitCanvasDeclaration() - ]; - config.CanvasHandler = new SessionCanvasHandler(_canvasWindowManager); - } - - ApplySystemMessageCustomization(config, defaultModel); - - cancellationToken.ThrowIfCancellationRequested(); - - client = await _clientFeature.GetClientAsync(cancellationToken); - CopilotSession createdSession = await client.CreateSessionAsync(config, cancellationToken); - sdkSession = createdSession; - - if(cancellationToken.IsCancellationRequested) - { - await client.DeleteSessionAsync(createdSession.SessionId, CancellationToken.None); - await createdSession.DisposeAsync(); - sdkSession = null; - cancellationToken.ThrowIfCancellationRequested(); - } - - _sdkRegistry.Register(createdSession, evt => - { - _logger.LogDebug("Session {SessionId} event: {EventType}", createdSession.SessionId, evt.Type); - HandleSessionEvent(createdSession.SessionId, evt); - }); - sdkSessionRegistered = true; - - // git context was started before the SDK round-trip; collect it now that it's needed. - GitContext? gitContext = await gitContextTask; - - SessionModel chatSession = new() - { - Id = createdSession.SessionId, - Title = "New Session", - CreatedAt = DateTime.UtcNow, - LastActivity = DateTime.UtcNow, - Status = SessionStatusEnum.Idle, - Context = new() - { - CurrentWorkingDirectory = workingDirectory, - WorkspacePath = createdSession.WorkspacePath, - GitRoot = gitContext?.GitRoot, - Repository = gitContext?.Repository, - Branch = gitContext?.Branch - }, - Model = defaultModel, - ReasoningEffort = defaultModel.DefaultReasoningEffort, - SdkState = SdkSessionStateEnum.Resumed - }; - - SessionWorkingDirectoryNormalizer.ApplyContextConsistency(chatSession.Context); - - cancellationToken.ThrowIfCancellationRequested(); - - await LoadContextPanelDataAsync(chatSession, createdSession); - - cancellationToken.ThrowIfCancellationRequested(); - - _sdkSessionByokId[chatSession.Id] = chatSession.ByokConfigId; - - _sessionListFeature.AddSession(chatSession); - - // These three writes are best-effort metadata used only to *resume* the session later - // (saved model id, agent, agent-mode). They have no bearing on the SessionModel returned - // to the UI or on SwitchCurrentSessionAsync below, so there's no reason to make the user - // wait on disk I/O — persist them in the background and just log any failure. - _ = PersistSessionMetadataInBackground(chatSession); - - await SwitchCurrentSessionAsync(chatSession); - - return chatSession; - } - catch(OperationCanceledException) - { - if(client is not null && sdkSession is not null) - { - string sessionId = sdkSession.SessionId; - try - { - await client.DeleteSessionAsync(sessionId, CancellationToken.None); - - if(sdkSessionRegistered) - { - _sdkRegistry.Remove(sessionId); - } - - await sdkSession.DisposeAsync(); - sdkSession = null; - } - catch(Exception cleanupEx) - { - _logger.LogWarning(cleanupEx, "Failed to cleanup canceled session {SessionId}", sessionId); - } - } - - throw; - } - catch(Exception ex) - { - _logger.LogError(ex, "Failed to create new session"); - throw; - } - } - - /// - /// Persists the best-effort resume metadata (model, agent, agent-mode) for a freshly created - /// session off the critical path. The three writes target independent files, so they run - /// concurrently; the whole operation is offloaded to the thread pool so session creation never - /// blocks on disk I/O. Each writer already swallows its own failures, but any unexpected fault - /// is logged here rather than left unobserved. - /// - Task PersistSessionMetadataInBackground(SessionModel chatSession) - => Task.Run(async () => - { - try - { - await Task.WhenAll( - _modelFeature.SaveSessionModel(chatSession), - _agentPersistence.SaveSessionAgent(chatSession), - _sessionModePersistence.SaveSessionMode(chatSession, CancellationToken.None)); - } - catch(Exception ex) - { - _logger.LogWarning(ex, "Background persistence failed for new session {SessionId}", chatSession.Id); - } - }); - - /// - /// Loads a session by replaying its history into the UI with DisableResume=true, which - /// suppresses the session.resume event so that merely viewing a session does not update - /// LastActivity. The SDK session is registered and ready to send messages; calling - /// on first message send simply promotes the flags. - /// - public async Task LoadSession(string sessionId) - { - try - { - SessionModel? session = _sessionListFeature.Sessions.FirstOrDefault(s => s.Id == sessionId); - if(session is null) - { - _logger.LogWarning("Session {SessionId} not found", sessionId); - return false; - } - - if(session.SdkState != SdkSessionStateEnum.NotLoaded) - { - _logger.LogInformation("Session {SessionId} already loaded or loading, switching to it", sessionId); - await SwitchCurrentSessionAsync(session); - - // Guard: eviction may have cleared the session between the state check and SwitchCurrentSessionAsync. - // If state is now NotLoaded, fall through to perform a full reload. - if(session.SdkState != SdkSessionStateEnum.NotLoaded) - { - return true; - } - - _logger.LogInformation("Session {SessionId} was evicted during switch; performing full load", sessionId); - } - - _logger.LogInformation("Loading session {SessionId}", sessionId); - - session.Context.CurrentWorkingDirectory = SessionWorkingDirectoryNormalizer.Normalize(session.Context.CurrentWorkingDirectory); - - if(string.IsNullOrWhiteSpace(session.Context.CurrentWorkingDirectory) || !Directory.Exists(session.Context.CurrentWorkingDirectory)) - { - session.Context.CurrentWorkingDirectory = null; - } - - SessionWorkingDirectoryNormalizer.ApplyContextConsistency(session.Context); - - GitHub.Copilot.ProviderConfig? providerConfig = await _modelFeature.GetProviderConfig(session.Model.Id); - - // BYOK providers don't support Copilot-specific reasoning effort; always pass null for them. - // This also guards against stale "medium" values loaded from pre-switch sessions before - // TryRestoreModelSettings has had a chance to clear the effort. - string? effectiveReasoningEffort = providerConfig is null ? session.ReasoningEffort : null; - - ResumeSessionConfig config = new() - { - ClientName = "Cockpit", - EnableConfigDiscovery = true, - Model = session.Model.Id, - ReasoningEffort = effectiveReasoningEffort, - Streaming = true, - SuppressResumeEvent = true, - WorkingDirectory = session.Context.CurrentWorkingDirectory, - OnPermissionRequest = _permissionHandler.HandlePermissionRequest, - OnUserInputRequest = _userInputHandler.HandleUserInputRequest, - OnElicitationRequest = _elicitationHandler.HandleElicitationRequest, - Hooks = _hooksFactory.CreateHooks(session.Model.Id, effectiveReasoningEffort, session.Context.CurrentWorkingDirectory, disableResume: true), - Provider = providerConfig - }; - - ApplySystemMessageCustomization(config, session.Model); - - if(_appSettingsFeature.CanvasEnabled) - { - config.RequestCanvasRenderer = true; - config.RequestExtensions = true; - config.ExtensionInfo = new ExtensionInfo { Source = "cockpit", Name = "canvas-provider" }; - config.Canvases = - [ - CreateCockpitCanvasDeclaration() - ]; - config.CanvasHandler = new SessionCanvasHandler(_canvasWindowManager); - } - - session.SdkState = SdkSessionStateEnum.Loading; - _sessionListFeature.NotifyStateChanged(); - - CopilotClient client = await _clientFeature.GetClientAsync(); - CopilotSession sdkSession = await client.ResumeSessionAsync(sessionId, config); - - // The context-panel load and the event replay below are independent: the replay rebuilds - // session.Messages and may mutate session.Context through SessionContextChangedEvent - // (which processes during replay), while LoadContextPanelDataAsync also writes - // session.Context. To prevent concurrent mutations, pass replay a snapshot of Context - // rather than the live reference. Replayed context mutations are discarded after replay - // completes, since LoadContextPanelDataAsync provides the authoritative values. - // Run the panel's SDK round-trips concurrently with the (length-dependent) event fetch + - // replay so they hide under it instead of adding to resume time. Joined before the restore - // section below, which reads session.Context. - Task contextPanelTask = LoadContextPanelDataAsync(session, sdkSession); - - bool registered = false; - try - { - IReadOnlyList events = await sdkSession.GetEventsAsync(CancellationToken.None); - _logger.LogInformation("Loading {Count} events for session {SessionId}", events.Count, sessionId); - - // Create a snapshot of the context for replay to mutate independently, avoiding - // concurrent writes with LoadContextPanelDataAsync. - Models.SessionContext replayContext = new() - { - CurrentWorkingDirectory = session.Context.CurrentWorkingDirectory, - WorkspacePath = session.Context.WorkspacePath, - GitRoot = session.Context.GitRoot, - Repository = session.Context.Repository, - Branch = session.Context.Branch, - EditedFiles = [], - AllowedCommands = [], - SessionPermissionCommands = [] - }; - - SessionModel tempSession = new() - { - Id = sessionId, - Title = session.Title, - Status = SessionStatusEnum.Idle, - Model = session.Model, - ReasoningEffort = session.ReasoningEffort, - Context = replayContext, - LastActivity = session.LastActivity, - CreatedAt = session.CreatedAt, - SuppressFinishedNotification = true - }; - - await Task.Run(() => - { - foreach(SessionEvent evt in events) - { - _processor.Process(tempSession, evt); - } - - if(tempSession.ActiveWorkingGroup is not null) - { - _processor.FinalizeOpenGroup(tempSession); - } - }); - tempSession.SuppressFinishedNotification = false; - - // Any message still IsPending after replay was sent while the session was - // mid-turn and never picked up by a subsequent assistant.turn_start (the session - // was interrupted). Clear the flag so history renders in the correct order and - // without the "Pending…" indicator. - foreach(ChatMessageModel msg in tempSession.Messages) - { - msg.IsPending = false; - } - - session.Messages = tempSession.Messages; - session.ActiveWorkingGroup = null; - if(session.Title != tempSession.Title) - { - session.Title = tempSession.Title; - } - - // Join the context-panel load before the restore section below, which reads - // session.Context (e.g. resolving the selected agent against the loaded agent list). - await contextPanelTask; - - session.Status = SessionStatusEnum.Idle; - session.SdkState = SdkSessionStateEnum.Loaded; - session.Context.WorkspacePath = sdkSession.WorkspacePath; - SessionPermissionFeature.TryRestoreSessionCommands(session, _logger); - await _modelFeature.TryRestoreModelSettings(session); - await _agentPersistence.TryRestoreSessionAgent(session); - if(session.Context.SelectedAgent is not null) - { - await sdkSession.Rpc.Agent.SelectAsync(session.Context.SelectedAgent.Name); - } - - await _sessionModePersistence.TryRestoreSessionMode(session); - if(session.Context.SelectedAgentMode != Models.SessionAgentModeEnum.Interactive) - { - await sdkSession.Rpc.Mode.SetAsync(session.Context.SelectedAgentMode.ToSdkSessionMode()); - } - - _sdkRegistry.Register(sdkSession, evt => - { - _logger.LogDebug("Session {SessionId} event: {EventType}", sdkSession.SessionId, evt.Type); - HandleSessionEvent(sdkSession.SessionId, evt); - }); - registered = true; - _sdkSessionByokId[sessionId] = session.ByokConfigId; - - await SwitchCurrentSessionAsync(session); - _logger.LogInformation("Successfully loaded session {SessionId} with {MessageCount} messages", sessionId, session.Messages.Count); - return true; - } - finally - { - if(!registered) - { - // The context-panel load may still be in flight on an error path. Wait for it - // (observing any failure) before disposing the SDK session it reads from. - try { await contextPanelTask; } catch { /* surfaced via the outer catch / loaders */ } - await sdkSession.DisposeAsync(); - session.SdkState = SdkSessionStateEnum.NotLoaded; - } - } - } - catch(Exception ex) when(ex.Message.Equals("Communication error with Copilot CLI: Request session.resume failed with message: Session file is corrupted or incompatible")) - { - _logger.LogError(ex, "Session {SessionId} is corrupted or incompatible", sessionId); - SessionModel? failedSession = _sessionListFeature.Sessions.FirstOrDefault(s => s.Id == sessionId); - failedSession?.SdkState = SdkSessionStateEnum.NotLoaded; - _sessionListFeature.NotifyStateChanged(); - _toastService.Error("Session Unavailable", opts => - { - opts.Description = "The session file may be corrupted, incompatible, or in use by another instance. You may need to delete or exit the session running else where"; - }); - return false; - } - catch(Exception ex) - { - _logger.LogError(ex, "Failed to load session {SessionId}", sessionId); - SessionModel? failedSession = _sessionListFeature.Sessions.FirstOrDefault(s => s.Id == sessionId); - failedSession?.SdkState = SdkSessionStateEnum.NotLoaded; - _sessionListFeature.NotifyStateChanged(); - return false; - } - } - /// /// Promotes a loaded session to fully resumed by flipping flags. Call this before sending the first /// message on a session that was loaded via . If the session has not been @@ -575,13 +32,13 @@ public async Task ResumeSession(string sessionId) return false; } - if(session.SdkState == SdkSessionStateEnum.Resumed) + if(session.Lifecycle.SdkState == SdkSessionStateEnum.Resumed) { _logger.LogInformation("Session {SessionId} already resumed", sessionId); return true; } - if(session.SdkState != SdkSessionStateEnum.Loaded) + if(session.Lifecycle.SdkState != SdkSessionStateEnum.Loaded) { bool loaded = await LoadSession(sessionId); if(!loaded) @@ -590,7 +47,7 @@ public async Task ResumeSession(string sessionId) } } - session.SdkState = SdkSessionStateEnum.Resumed; + session.Lifecycle.SdkState = SdkSessionStateEnum.Resumed; _logger.LogInformation("Session {SessionId} promoted from loaded to resumed", sessionId); return true; } @@ -803,14 +260,11 @@ public async Task AbortSession(string sessionId) throw new InvalidOperationException($"Session {sessionId} not found in SDK sessions"); } - // Clear status history so that resolving pending requests restores to Idle, not Running + // Transition the lifecycle state first so resolving pending interactions reveals Idle. SessionModel? session = _sessionListFeature.Sessions.FirstOrDefault(s => s.Id == sessionId); if(session is not null) { - lock(session.StatusHistoryLock) - { - session.StatusHistory.Clear(); - } + session.Lifecycle.AgentRunState = AgentRunStateEnum.Idle; } // Cancel any pending permission/user-input/elicitation requests so they are removed from the UI immediately @@ -826,187 +280,4 @@ public async Task AbortSession(string sessionId) } } - /// - /// Debug helper: clears the current session's messages and replays them from the SDK history, - /// introducing timestamp-proportional delays between events so the replay feels like a live session. - /// - public async Task ReplayCurrentSessionAsync(CancellationToken cancellationToken = default) - { - SessionModel? session = _sessionListFeature.CurrentSession; - if(session is null) - { - _logger.LogWarning("ReplayCurrentSession: no current session"); - return; - } - - if(!_sdkRegistry.TryGet(session.Id, out CopilotSession? sdkSession)) - { - _logger.LogWarning("ReplayCurrentSession: SDK session not found for {SessionId}", session.Id); - return; - } - - try - { - IReadOnlyList events = await sdkSession.GetEventsAsync(cancellationToken); - _logger.LogInformation("Replaying {Count} events for session {SessionId}", events.Count, session.Id); - - lock(session.SessionEventLock) - { - session.Messages.Clear(); - session.ActiveWorkingGroup = null; - session.MessagesSnapshot = []; - } - _sessionListFeature.NotifyStateChanged(); - - // Same parentId-based immediate-mode detection used during live sessions applies - // here — no pre-processing or reordering needed. - Task streamCallback(ChatMessageModel msg, string text) => SessionEventHelpers.StreamSummaryTextAsync(msg, text, _sessionListFeature.NotifyStateChanged); - - DateTimeOffset? prevTimestamp = null; - foreach(SessionEvent evt in events) - { - cancellationToken.ThrowIfCancellationRequested(); - - if(prevTimestamp.HasValue) - { - TimeSpan realGap = evt.Timestamp - prevTimestamp.Value; - int delayMs = (int)Math.Clamp(realGap.TotalMilliseconds, 50, 3000); - await Task.Delay(delayMs, cancellationToken); - } - - lock(session.SessionEventLock) - { - _processor.Process(session, evt, streamCallback); - session.MessagesSnapshot = [.. session.Messages]; - } - _sessionListFeature.NotifyStateChanged(); - - prevTimestamp = evt.Timestamp; - } - - lock(session.SessionEventLock) - { - if(session.ActiveWorkingGroup is not null) - { - _processor.FinalizeOpenGroup(session); - } - session.MessagesSnapshot = [.. session.Messages]; - } - _sessionListFeature.NotifyStateChanged(); - - _logger.LogInformation("Replay complete for session {SessionId} — {MessageCount} messages", session.Id, session.Messages.Count); - } - catch(OperationCanceledException) - { - _logger.LogInformation("Replay cancelled for session {SessionId}", session.Id); - } - catch(Exception ex) - { - _logger.LogError(ex, "Replay failed for session {SessionId}", session.Id); - } - } - - async Task LoadContextPanelDataAsync(SessionModel session, CopilotSession sdkSession) - { - Task> agentsTask = _agentFeature.LoadSessionAgentsAsync(sdkSession, session.Context.GitRoot); - Task> instructionsTask = _instructionsFeature.LoadSessionInstructionsAsync(sdkSession); - Task> mcpTask = _mcpFeature.LoadSessionMcpServersAsync(sdkSession); - Task> skillsTask = _skillsFeature.LoadSessionSkillsAsync(sdkSession); - Task> pluginsTask = _pluginsFeature.LoadSessionPluginsAsync(sdkSession); - - await Task.WhenAll(agentsTask, instructionsTask, mcpTask, skillsTask, pluginsTask); - - session.Context.Agents = agentsTask.Result; - session.Context.Instructions = instructionsTask.Result; - session.Context.McpServers = mcpTask.Result; - session.Context.Skills = skillsTask.Result; - session.Context.Plugins = pluginsTask.Result; - } - - static SectionOverride? MapToSectionOverride(SystemMessageSectionSetting setting) - => setting.Action switch - { - SystemMessageOverrideAction.Replace => new SectionOverride { Action = SectionOverrideAction.Replace, Content = setting.Content }, - SystemMessageOverrideAction.Remove => new SectionOverride { Action = SectionOverrideAction.Remove }, - SystemMessageOverrideAction.Append => new SectionOverride { Action = SectionOverrideAction.Append, Content = setting.Content }, - SystemMessageOverrideAction.Prepend => new SectionOverride { Action = SectionOverrideAction.Prepend, Content = setting.Content }, - _ => null // None → skip - }; - - static CanvasDeclaration CreateCockpitCanvasDeclaration() - => new() - { - Id = "cockpit-canvas", - DisplayName = "Cockpit Canvas", - Description = "Opens a visual canvas window alongside your message — it is an enhancement, NOT a replacement for your reply. IMPORTANT: you must ALWAYS write a complete, self-contained assistant message in addition to invoking this canvas. The canvas is ephemeral: it is not retained when the user resumes or revisits a session, so your text message is the durable record the user will rely on. Everything you show in the canvas must also be communicated meaningfully in your message (e.g. summarise a chart's findings, include the table data as markdown, restate the diagram as prose). Never rely on the canvas as the sole delivery of information. Provide a JSON object with \"html\" (required) containing styled HTML to render, and \"title\" (optional) for the window title. Content is rendered inside a sandboxed iframe (allow-scripts only) — scripts execute but have no access to the parent window, storage, or navigation. Tailwind CSS v3 is available (all utilities) and script tags execute in insertion order. External CDN script tags are NOT supported (the sandbox blocks network requests from the null origin); use only inline scripts and the preloaded libraries (Chart.js, Mermaid). CSS vars --bg-color, --text-color, --title-color, --secondary-text, --accent-color, --border-color, --sidebar-color, --hover-color are available for theming. Provide: \"html\" (required) rich interactive HTML; \"title\" (optional) window title.", - InputSchema = JsonSerializer.SerializeToElement(new - { - type = "object", - required = new[] { "html" }, - properties = new - { - html = new - { - type = "string", - description = "Rich HTML rendered in a sandboxed iframe inside the canvas window. IMPORTANT: the canvas is a visual enhancement only — it is ephemeral and will NOT be visible when the user resumes this session later. You must always accompany this canvas invocation with a complete text message that conveys the same information (e.g. markdown table, prose summary, or code block) so the user retains full value even without the canvas. The sandbox allows script execution but blocks access to the parent window, cookies, storage, forms, popups, and navigation. The native window chrome already displays the canvas title, so do not duplicate it in the HTML unless you intentionally want a separate in-content heading. Tailwind CSS v3 and Cockpit app.css classes are available: bg-app-bg, bg-app-sidebar, border-app-border, bg-app-hover, bg-app-active, text-app-text, text-app-title, secondary-text, accent-btn, scrollbar-thin. Mermaid (strict security level) and Chart.js are preloaded locally — do NOT add CDN script tags for these or any other external libraries (the sandboxed iframe has a null origin and cannot fetch external resources). For Mermaid, use
...
instead of markdown fences or raw code blocks. For Chart.js, create a canvas element and instantiate new Chart(...) in a following inline script; maintainAspectRatio is always enforced to true and cannot be overridden — do NOT set maintainAspectRatio to false. Inline scripts run in insertion order. CSS vars --bg-color, --sidebar-color, --border-color, --hover-color, --active-color, --text-color, --title-color, --secondary-text, --accent-color, --button-bg, --button-hover are available for theming." - }, - title = new { type = "string", description = "Optional window title bar text." } - } - }) - }; - - void ApplySystemMessageCustomization(SessionConfig config, ModelInfo model) - => config.SystemMessage = CreateSystemMessageConfig(model.Name, model.Id); - - void ApplySystemMessageCustomization(ResumeSessionConfig config, ModelInfo model) - => config.SystemMessage = CreateSystemMessageConfig(model.Name, model.Id); - - void ApplySystemMessageCustomization(SessionConfig config, ModelInfo? model, string fallbackModelId) - => config.SystemMessage = CreateSystemMessageConfig(model?.Name ?? fallbackModelId, model?.Id ?? fallbackModelId); - - void ApplySystemMessageCustomization(ResumeSessionConfig config, ModelInfo? model, string fallbackModelId) - => config.SystemMessage = CreateSystemMessageConfig(model?.Name ?? fallbackModelId, model?.Id ?? fallbackModelId); - - SystemMessageConfig CreateSystemMessageConfig(string modelName, string modelId) - { - Dictionary sections = []; - foreach(KeyValuePair kvp in _appSettingsFeature.SystemMessageSectionOverrides) - { - SectionOverride? sectionOverride = MapToSectionOverride(kvp.Value); - if(sectionOverride is not null) - { - sections[new SystemMessageSection(kvp.Key)] = sectionOverride; - } - } - - sections[SystemMessageSection.Identity] = new SectionOverride() - { - Action = SectionOverrideAction.Replace, - Content = $""" - You are Cockpit, a desktop application that provides a graphical interface for GitHub Copilot CLI. You are an interactive AI assistant that helps users with software engineering tasks through a desktop GUI experience. - - # Search and delegation - - * When prompting sub-agents, provide comprehensive context — brevity rules do not apply to sub-agent prompts. - * When searching the file system for files or text, stay in the current working directory or child directories of the cwd unless absolutely necessary. - * When searching code, the preference order for tools to use is: code intelligence tools (if available) > LSP-based tools (if available) > glob > rg with glob pattern > powershell tool. - - Powered by . - - When asked who you are, reply with something like: "I'm Cockpit, a desktop application that provides a graphical interface for GitHub Copilot CLI, powered by GPT-5.4 mini (model ID: gpt-5.4-mini)." - - When asked which model is being used, reply with something like: "I'm powered by {modelName} (model ID: {modelId})." - - If the model was changed during the conversation, acknowledge the change and respond accordingly. - - Your job is to perform the task the user requested while providing a desktop-first experience through Cockpit. - """ - }; - - return new SystemMessageConfig - { - Mode = SystemMessageMode.Customize, - Sections = sections - }; - } } diff --git a/src/Cockpit/Features/Sessions/SessionFeature.Loading.cs b/src/Cockpit/Features/Sessions/SessionFeature.Loading.cs new file mode 100644 index 00000000..520a29f6 --- /dev/null +++ b/src/Cockpit/Features/Sessions/SessionFeature.Loading.cs @@ -0,0 +1,298 @@ +using System.Text.Json; +using Cockpit.Features.Agents.Models; +using Cockpit.Features.Canvas; +using Cockpit.Features.Git.Models; +using Cockpit.Features.Permissions; +using Cockpit.Features.SessionEvents; +using Cockpit.Features.SessionEvents.Models; +using Cockpit.Features.Sessions.Interactions; +using Cockpit.Features.Sessions.Models; +using Cockpit.Features.SystemMessage; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Microsoft.Extensions.Logging; +using SdkPlugin = GitHub.Copilot.Rpc.Plugin; +using SdkSessionMetadata = GitHub.Copilot.SessionMetadata; + +namespace Cockpit.Features.Sessions; + +public sealed partial class SessionFeature +{ + /// + /// Loads a session by replaying its history into the UI with DisableResume=true, which + /// suppresses the session.resume event so that merely viewing a session does not update + /// LastActivity. The SDK session is registered and ready to send messages; calling + /// on first message send simply promotes the flags. + /// + public async Task LoadSession(string sessionId) + { + try + { + SessionModel? session = _sessionListFeature.Sessions.FirstOrDefault(s => s.Id == sessionId); + if(session is null) + { + _logger.LogWarning("Session {SessionId} not found", sessionId); + return false; + } + + if(session.Lifecycle.SdkState != SdkSessionStateEnum.NotLoaded) + { + _logger.LogInformation("Session {SessionId} already loaded or loading, switching to it", sessionId); + await SwitchCurrentSessionAsync(session); + + // Guard: eviction may have cleared the session between the state check and SwitchCurrentSessionAsync. + // If state is now NotLoaded, fall through to perform a full reload. + if(session.Lifecycle.SdkState != SdkSessionStateEnum.NotLoaded) + { + return true; + } + + _logger.LogInformation("Session {SessionId} was evicted during switch; performing full load", sessionId); + } + + _logger.LogInformation("Loading session {SessionId}", sessionId); + + session.Context.CurrentWorkingDirectory = SessionWorkingDirectoryNormalizer.Normalize(session.Context.CurrentWorkingDirectory); + + if(string.IsNullOrWhiteSpace(session.Context.CurrentWorkingDirectory) || !Directory.Exists(session.Context.CurrentWorkingDirectory)) + { + session.Context.CurrentWorkingDirectory = null; + } + + SessionWorkingDirectoryNormalizer.ApplyContextConsistency(session.Context); + + GitHub.Copilot.ProviderConfig? providerConfig = await _modelFeature.GetProviderConfig(session.Model.Id); + + // BYOK providers don't support Copilot-specific reasoning effort; always pass null for them. + // This also guards against stale "medium" values loaded from pre-switch sessions before + // TryRestoreModelSettings has had a chance to clear the effort. + string? effectiveReasoningEffort = providerConfig is null ? session.ReasoningEffort : null; + + ResumeSessionConfig config = new() + { + ClientName = "Cockpit", + EnableConfigDiscovery = true, + Model = session.Model.Id, + ReasoningEffort = effectiveReasoningEffort, + Streaming = true, + SuppressResumeEvent = true, + WorkingDirectory = session.Context.CurrentWorkingDirectory, + OnPermissionRequest = _permissionHandler.HandlePermissionRequest, + OnUserInputRequest = _userInputHandler.HandleUserInputRequest, + OnElicitationRequest = _elicitationHandler.HandleElicitationRequest, + Hooks = _hooksFactory.CreateHooks(session.Model.Id, effectiveReasoningEffort, session.Context.CurrentWorkingDirectory, disableResume: true), + Provider = providerConfig + }; + + ApplySystemMessageCustomization(config, session.Model); + + if(_appSettingsFeature.CanvasEnabled) + { + config.RequestCanvasRenderer = true; + config.RequestExtensions = true; + config.ExtensionInfo = new ExtensionInfo { Source = "cockpit", Name = "canvas-provider" }; + config.Canvases = + [ + CreateCockpitCanvasDeclaration() + ]; + config.CanvasHandler = new SessionCanvasHandler(_canvasWindowManager); + } + + session.Lifecycle.SdkState = SdkSessionStateEnum.Loading; + _sessionListFeature.NotifyStateChanged(); + + CopilotClient client = await _clientFeature.GetClientAsync(); + CopilotSession sdkSession = await client.ResumeSessionAsync(sessionId, config); + + // The context-panel load and the event replay below are independent: the replay rebuilds + // session.Messages and may mutate session.Context through SessionContextChangedEvent + // (which processes during replay), while LoadContextPanelDataAsync also writes + // session.Context. To prevent concurrent mutations, pass replay a snapshot of Context + // rather than the live reference. Replayed context mutations are discarded after replay + // completes, since LoadContextPanelDataAsync provides the authoritative values. + // Run the panel's SDK round-trips concurrently with the (length-dependent) event fetch + + // replay so they hide under it instead of adding to resume time. Joined before the restore + // section below, which reads session.Context. + Task contextPanelTask = LoadContextPanelDataAsync(session, sdkSession); + + bool registered = false; + try + { + IReadOnlyList events = await sdkSession.GetEventsAsync(CancellationToken.None); + _logger.LogInformation("Loading {Count} events for session {SessionId}", events.Count, sessionId); + + // Create a snapshot of the context for replay to mutate independently, avoiding + // concurrent writes with LoadContextPanelDataAsync. + Models.SessionContext replayContext = new() + { + CurrentWorkingDirectory = session.Context.CurrentWorkingDirectory, + WorkspacePath = session.Context.WorkspacePath, + GitRoot = session.Context.GitRoot, + Repository = session.Context.Repository, + Branch = session.Context.Branch, + EditedFiles = [], + AllowedCommands = [], + SessionPermissionCommands = [] + }; + + SessionModel tempSession = new() + { + Id = sessionId, + Title = session.Title, + AgentRunState = AgentRunStateEnum.Idle, + Model = session.Model, + ReasoningEffort = session.ReasoningEffort, + Context = replayContext, + LastActivity = session.LastActivity, + CreatedAt = session.CreatedAt, + SuppressFinishedNotification = true + }; + + await Task.Run(() => + { + foreach(SessionEvent evt in events) + { + _processor.Process(tempSession, evt); + } + + if(tempSession.ActiveWorkingGroup is not null) + { + _processor.FinalizeOpenGroup(tempSession); + } + }); + tempSession.Lifecycle.SuppressFinishedNotification = false; + + // Any message still IsPending after replay was sent while the session was + // mid-turn and never picked up by a subsequent assistant.turn_start (the session + // was interrupted). Clear the flag so history renders in the correct order and + // without the "Pending…" indicator. + foreach(ChatMessageModel msg in tempSession.Messages) + { + msg.IsPending = false; + } + + session.Conversation.ReplaceMessages(tempSession.Conversation.Messages); + session.ActiveWorkingGroup = null; + if(session.Title != tempSession.Title) + { + session.Title = tempSession.Title; + } + + // Join the context-panel load before the restore section below, which reads + // session.Context (e.g. resolving the selected agent against the loaded agent list). + await contextPanelTask; + + session.Lifecycle.AgentRunState = AgentRunStateEnum.Idle; + session.Lifecycle.SdkState = SdkSessionStateEnum.Loaded; + session.Context.WorkspacePath = sdkSession.WorkspacePath; + SessionPermissionFeature.TryRestoreSessionCommands(session, _logger); + await _modelFeature.TryRestoreModelSettings(session); + await _agentPersistence.TryRestoreSessionAgent(session); + if(session.Context.SelectedAgent is not null) + { + await sdkSession.Rpc.Agent.SelectAsync(session.Context.SelectedAgent.Name); + } + + await _sessionModePersistence.TryRestoreSessionMode(session); + if(session.Context.SelectedAgentMode != Models.SessionAgentModeEnum.Interactive) + { + await sdkSession.Rpc.Mode.SetAsync(session.Context.SelectedAgentMode.ToSdkSessionMode()); + } + + _sdkRegistry.Register(sdkSession, evt => + { + _logger.LogDebug("Session {SessionId} event: {EventType}", sdkSession.SessionId, evt.Type); + HandleSessionEvent(sdkSession.SessionId, evt); + }); + registered = true; + _sdkSessionByokId[sessionId] = session.ByokConfigId; + + await SwitchCurrentSessionAsync(session); + _logger.LogInformation("Successfully loaded session {SessionId} with {MessageCount} messages", sessionId, session.Messages.Count); + return true; + } + finally + { + if(!registered) + { + // The context-panel load may still be in flight on an error path. Wait for it + // (observing any failure) before disposing the SDK session it reads from. + try { await contextPanelTask; } catch { /* surfaced via the outer catch / loaders */ } + await sdkSession.DisposeAsync(); + session.Lifecycle.SdkState = SdkSessionStateEnum.NotLoaded; + } + } + } + catch(Exception ex) when(ex.Message.Equals("Communication error with Copilot CLI: Request session.resume failed with message: Session file is corrupted or incompatible")) + { + _logger.LogError(ex, "Session {SessionId} is corrupted or incompatible", sessionId); + SessionModel? failedSession = _sessionListFeature.Sessions.FirstOrDefault(s => s.Id == sessionId); + failedSession?.Lifecycle.SdkState = SdkSessionStateEnum.NotLoaded; + _sessionListFeature.NotifyStateChanged(); + _toastService.Error("Session Unavailable", opts => + { + opts.Description = "The session file may be corrupted, incompatible, or in use by another instance. You may need to delete or exit the session running else where"; + }); + return false; + } + catch(Exception ex) + { + _logger.LogError(ex, "Failed to load session {SessionId}", sessionId); + SessionModel? failedSession = _sessionListFeature.Sessions.FirstOrDefault(s => s.Id == sessionId); + failedSession?.Lifecycle.SdkState = SdkSessionStateEnum.NotLoaded; + _sessionListFeature.NotifyStateChanged(); + return false; + } + } + + + async Task LoadContextPanelDataAsync(SessionModel session, CopilotSession sdkSession) + { + Task> agentsTask = _agentFeature.LoadSessionAgentsAsync(sdkSession, session.Context.GitRoot); + Task> instructionsTask = _instructionsFeature.LoadSessionInstructionsAsync(sdkSession); + Task> mcpTask = _mcpFeature.LoadSessionMcpServersAsync(sdkSession); + Task> skillsTask = _skillsFeature.LoadSessionSkillsAsync(sdkSession); + Task> pluginsTask = _pluginsFeature.LoadSessionPluginsAsync(sdkSession); + + await Task.WhenAll(agentsTask, instructionsTask, mcpTask, skillsTask, pluginsTask); + + session.Context.Agents = agentsTask.Result; + session.Context.Instructions = instructionsTask.Result; + session.Context.McpServers = mcpTask.Result; + session.Context.Skills = skillsTask.Result; + session.Context.Plugins = pluginsTask.Result; + } + + static SectionOverride? MapToSectionOverride(SystemMessageSectionSetting setting) + => setting.Action switch + { + SystemMessageOverrideAction.Replace => new SectionOverride { Action = SectionOverrideAction.Replace, Content = setting.Content }, + SystemMessageOverrideAction.Remove => new SectionOverride { Action = SectionOverrideAction.Remove }, + SystemMessageOverrideAction.Append => new SectionOverride { Action = SectionOverrideAction.Append, Content = setting.Content }, + SystemMessageOverrideAction.Prepend => new SectionOverride { Action = SectionOverrideAction.Prepend, Content = setting.Content }, + _ => null // None → skip + }; + + static CanvasDeclaration CreateCockpitCanvasDeclaration() + => new() + { + Id = "cockpit-canvas", + DisplayName = "Cockpit Canvas", + Description = "Opens a visual canvas window alongside your message — it is an enhancement, NOT a replacement for your reply. IMPORTANT: you must ALWAYS write a complete, self-contained assistant message in addition to invoking this canvas. The canvas is ephemeral: it is not retained when the user resumes or revisits a session, so your text message is the durable record the user will rely on. Everything you show in the canvas must also be communicated meaningfully in your message (e.g. summarise a chart's findings, include the table data as markdown, restate the diagram as prose). Never rely on the canvas as the sole delivery of information. Provide a JSON object with \"html\" (required) containing styled HTML to render, and \"title\" (optional) for the window title. Content is rendered inside a sandboxed iframe (allow-scripts only) — scripts execute but have no access to the parent window, storage, or navigation. Tailwind CSS v3 is available (all utilities) and script tags execute in insertion order. External CDN script tags are NOT supported (the sandbox blocks network requests from the null origin); use only inline scripts and the preloaded libraries (Chart.js, Mermaid). CSS vars --bg-color, --text-color, --title-color, --secondary-text, --accent-color, --border-color, --sidebar-color, --hover-color are available for theming. Provide: \"html\" (required) rich interactive HTML; \"title\" (optional) window title.", + InputSchema = JsonSerializer.SerializeToElement(new + { + type = "object", + required = new[] { "html" }, + properties = new + { + html = new + { + type = "string", + description = "Rich HTML rendered in a sandboxed iframe inside the canvas window. IMPORTANT: the canvas is a visual enhancement only — it is ephemeral and will NOT be visible when the user resumes this session later. You must always accompany this canvas invocation with a complete text message that conveys the same information (e.g. markdown table, prose summary, or code block) so the user retains full value even without the canvas. The sandbox allows script execution but blocks access to the parent window, cookies, storage, forms, popups, and navigation. The native window chrome already displays the canvas title, so do not duplicate it in the HTML unless you intentionally want a separate in-content heading. Tailwind CSS v3 and Cockpit app.css classes are available: bg-app-bg, bg-app-sidebar, border-app-border, bg-app-hover, bg-app-active, text-app-text, text-app-title, secondary-text, accent-btn, scrollbar-thin. Mermaid (strict security level) and Chart.js are preloaded locally — do NOT add CDN script tags for these or any other external libraries (the sandboxed iframe has a null origin and cannot fetch external resources). For Mermaid, use
...
instead of markdown fences or raw code blocks. For Chart.js, create a canvas element and instantiate new Chart(...) in a following inline script; maintainAspectRatio is always enforced to true and cannot be overridden — do NOT set maintainAspectRatio to false. Inline scripts run in insertion order. CSS vars --bg-color, --sidebar-color, --border-color, --hover-color, --active-color, --text-color, --title-color, --secondary-text, --accent-color, --button-bg, --button-hover are available for theming." + }, + title = new { type = "string", description = "Optional window title bar text." } + } + }) + }; + +} diff --git a/src/Cockpit/Features/Sessions/SessionFeature.Messages.cs b/src/Cockpit/Features/Sessions/SessionFeature.Messages.cs index ead79461..d7408e80 100644 --- a/src/Cockpit/Features/Sessions/SessionFeature.Messages.cs +++ b/src/Cockpit/Features/Sessions/SessionFeature.Messages.cs @@ -9,14 +9,20 @@ namespace Cockpit.Features.Sessions; public sealed partial class SessionFeature { - public async Task SendMessageAsync(string content, List? attachments = null, bool isInternalRetry = false) + public Task SendMessageAsync(string content, List? attachments = null, bool isInternalRetry = false) { - if(CurrentSession is null) - { - return; - } + SessionModel? session = CurrentSession; + return session is null + ? Task.CompletedTask + : SendMessageAsync(session, content, attachments, isInternalRetry); + } - SessionModel session = CurrentSession; + async Task SendMessageAsync( + SessionModel session, + string content, + List? attachments, + bool isInternalRetry) + { string sessionId = session.Id; ChatMessageModel? optimisticMessage = null; try @@ -26,7 +32,7 @@ public async Task SendMessageAsync(string content, List? attach throw new InvalidOperationException($"Session {sessionId} not found"); } - if(session.ModelChanged) + if(session.Lifecycle.ModelChanged) { ProviderConfig? newProviderConfig = await _modelFeature.GetProviderConfig(session.Model.Id); @@ -51,16 +57,16 @@ public async Task SendMessageAsync(string content, List? attach throw new InvalidOperationException($"Session {sessionId} not found after model restart"); } - session.ModelChanged = false; + session.Lifecycle.ModelChanged = false; } else { await existingSession.SetModelAsync(session.Model.Id, session.ReasoningEffort); - session.ModelChanged = false; + session.Lifecycle.ModelChanged = false; } } - if(session.AgentChanged) + if(session.Lifecycle.AgentChanged) { if(session.Context.SelectedAgent is null) { @@ -71,16 +77,16 @@ public async Task SendMessageAsync(string content, List? attach await existingSession.Rpc.Agent.SelectAsync(session.Context.SelectedAgent.Name); } - session.AgentChanged = false; + session.Lifecycle.AgentChanged = false; } - if(session.AgentModeChanged) + if(session.Lifecycle.AgentModeChanged) { await existingSession.Rpc.Mode.SetAsync(session.Context.SelectedAgentMode.ToSdkSessionMode()); - session.AgentModeChanged = false; + session.Lifecycle.AgentModeChanged = false; } - if(session.SdkState == SdkSessionStateEnum.Loaded) + if(session.Lifecycle.SdkState == SdkSessionStateEnum.Loaded) { bool resumed = await ResumeSession(sessionId); if(!resumed) @@ -100,10 +106,10 @@ public async Task SendMessageAsync(string content, List? attach MessageTurnModeEnum selectedTurnMode = _appSettingsFeature.MessageTurnMode; string turnMode = selectedTurnMode.ToSdkToken(); - lock(CurrentSession.SessionEventLock) + lock(session.SessionEventLock) { - bool agentWasBusy = CurrentSession.ActiveWorkingGroup is not null; - CurrentSession.Status = SessionStatusEnum.Running; + bool agentWasBusy = session.Conversation.ActiveWorkingGroup is not null; + session.Lifecycle.AgentRunState = AgentRunStateEnum.Running; optimisticMessage = new ChatMessageModel { @@ -117,19 +123,19 @@ public async Task SendMessageAsync(string content, List? attach Attachments = attachments?.Count > 0 ? attachments : null, EventJson = null }; - CurrentSession.Messages.Add(optimisticMessage); - CurrentSession.MessagesSnapshot = [.. CurrentSession.Messages]; + session.Conversation.Messages.Add(optimisticMessage); + session.Conversation.PublishMessagesSnapshot(); // For immediate (steering) mode: flag that a new turn is imminent so the // working panel and Running status are preserved through the idle transition. if(agentWasBusy && selectedTurnMode == MessageTurnModeEnum.Immediate) { - CurrentSession.HasQueuedImmediateMessage = true; + session.Conversation.HasQueuedImmediateMessage = true; } else if(!agentWasBusy) { // Clear any stale value (e.g. after a failed send) when no turn is in-flight. - CurrentSession.HasQueuedImmediateMessage = false; + session.Conversation.HasQueuedImmediateMessage = false; } } _sessionListFeature.NotifyStateChanged(); @@ -169,16 +175,16 @@ public async Task SendMessageAsync(string content, List? attach { lock(session.SessionEventLock) { - if(session.Messages.Contains(optimisticMessage) && !optimisticMessage.IsComplete) + if(session.Conversation.Messages.Contains(optimisticMessage) && !optimisticMessage.IsComplete) { string oldId = optimisticMessage.Id; optimisticMessage.Id = sentMessageId; // Keep the working group anchor in sync with the updated message ID. // assistant.turn_start may have captured the old GUID before SendAsync returned. - if(session.ActiveWorkingGroup?.TriggeredByUserMessageId == oldId) + if(session.Conversation.ActiveWorkingGroup?.TriggeredByUserMessageId == oldId) { - session.ActiveWorkingGroup.TriggeredByUserMessageId = sentMessageId; + session.Conversation.ActiveWorkingGroup.TriggeredByUserMessageId = sentMessageId; } } } @@ -200,20 +206,20 @@ public async Task SendMessageAsync(string content, List? attach { lock(session.SessionEventLock) { - session.Messages.Remove(optimisticMessage); - session.MessagesSnapshot = [.. session.Messages]; + session.Conversation.Messages.Remove(optimisticMessage); + session.Conversation.PublishMessagesSnapshot(); } _sessionListFeature.NotifyStateChanged(); } // Remove and dispose any previously registered SDK session before forcing a full re-resume - if(_sdkRegistry.TryRemove(CurrentSession.Id, out CopilotSession? existingSession)) + if(_sdkRegistry.TryRemove(sessionId, out CopilotSession? existingSession)) { await existingSession.DisposeAsync(); } // Reset state to NotLoaded so LoadSession performs a full re-resume via the SDK - session.SdkState = SdkSessionStateEnum.NotLoaded; + session.Lifecycle.SdkState = SdkSessionStateEnum.NotLoaded; bool resumed = await ResumeSession(sessionId); if(!resumed) { @@ -221,7 +227,7 @@ public async Task SendMessageAsync(string content, List? attach return; } - await SendMessageAsync(content, attachments, true); + await SendMessageAsync(session, content, attachments, true); } catch(Exception ex) { @@ -239,9 +245,9 @@ void HandleError(Exception ex) optimisticMessage.IsComplete = true; optimisticMessage.IsPending = false; } - session.Status = SessionStatusEnum.Error; + session.Lifecycle.AgentRunState = AgentRunStateEnum.Error; SessionErrorHandler.HandleException(session, ex); - session.MessagesSnapshot = [.. session.Messages]; + session.Conversation.PublishMessagesSnapshot(); } _sessionListFeature.NotifyStateChanged(); } @@ -272,7 +278,7 @@ public async Task RetryMessageAsync(ChatMessageModel message) } } - CurrentSession.MessagesSnapshot = [.. CurrentSession.Messages]; + CurrentSession.Conversation.PublishMessagesSnapshot(); } _sessionListFeature.NotifyStateChanged(); diff --git a/src/Cockpit/Features/Sessions/SessionFeature.Reconnect.cs b/src/Cockpit/Features/Sessions/SessionFeature.Reconnect.cs index 5a2f3080..07dad909 100644 --- a/src/Cockpit/Features/Sessions/SessionFeature.Reconnect.cs +++ b/src/Cockpit/Features/Sessions/SessionFeature.Reconnect.cs @@ -4,6 +4,7 @@ using Cockpit.Features.SessionEvents; using Cockpit.Features.SessionEvents.Handlers; using Cockpit.Features.SessionEvents.Models; +using Cockpit.Features.Sessions.Interactions; using Cockpit.Features.Sessions.Models; using GitHub.Copilot; using Microsoft.Extensions.Logging; @@ -86,7 +87,7 @@ async Task HandleClientDisconnectedAsync() lock(session.SessionEventLock) { - if(session.SdkState == SdkSessionStateEnum.NotLoaded) + if(session.Lifecycle.SdkState == SdkSessionStateEnum.NotLoaded) { continue; } @@ -96,32 +97,17 @@ async Task HandleClientDisconnectedAsync() session.StreamingThinkingEvents.Clear(); // Reset to NotLoaded so the reconnect handler (or user navigation) triggers a reload. - session.SdkState = SdkSessionStateEnum.NotLoaded; - - // If the session was blocked waiting for permission/input, those requests are now - // dead. Restore status so the working panel reflects the active group correctly. - // Guard: if there's no active group the session should end up Idle, not Running. - if(session.Status is SessionStatusEnum.NeedsPermission or SessionStatusEnum.NeedsUserInput or SessionStatusEnum.NeedsElicitation) - { - session.Status = session.ActiveWorkingGroup is not null - ? SessionStatusEnum.Running - : SessionStatusEnum.Idle; - } + session.Lifecycle.SdkState = SdkSessionStateEnum.NotLoaded; // Remove from registry inside the lock to stop event delivery on stale state. _sdkRegistry.TryRemove(session.Id, out sdkSession); - session.MessagesSnapshot = [.. session.Messages]; + session.Conversation.PublishMessagesSnapshot(); } // Clear blocking-request state outside SessionEventLock to avoid lock-ordering issues. - lock(session.StatusHistoryLock) - { - session.StatusHistory.Clear(); - } - - session.PendingPermissionRequests.Clear(); - session.PendingUserInputRequests.Clear(); - session.PendingElicitationRequests.Clear(); + _interactionCoordinator.ClearBookkeeping( + session.Id, + PendingInteractionKinds.All); _permissionHandler.CancelPendingRequestsForSession(session.Id); _userInputHandler.CancelPendingRequestsForSession(session.Id); @@ -160,7 +146,7 @@ async Task HandleClientReconnectedAsync() _logger.LogInformation("Client reconnected — resuming current session"); SessionModel? current = _sessionListFeature.CurrentSession; - if(current is null || current.SdkState != SdkSessionStateEnum.NotLoaded) + if(current is null || current.Lifecycle.SdkState != SdkSessionStateEnum.NotLoaded) { return; } @@ -200,7 +186,7 @@ async Task SilentResumeSessionAsync(SessionModel session) CopilotSession? sdkSession = null; try { - session.SdkState = SdkSessionStateEnum.Loading; + session.Lifecycle.SdkState = SdkSessionStateEnum.Loading; _sessionListFeature.NotifyStateChanged(); ProviderConfig? providerConfig = await _modelFeature.GetProviderConfig(session.Model.Id); @@ -237,7 +223,7 @@ async Task SilentResumeSessionAsync(SessionModel session) HandleSessionEvent(sdkSession.SessionId, evt); }); - session.SdkState = SdkSessionStateEnum.Resumed; + session.Lifecycle.SdkState = SdkSessionStateEnum.Resumed; _sessionListFeature.NotifyStateChanged(); // Send an internal continuation prompt. The content prefix causes SessionEventProcessor @@ -265,7 +251,7 @@ await sdkSession.SendAsync(new MessageOptions catch { } } - session.SdkState = SdkSessionStateEnum.NotLoaded; + session.Lifecycle.SdkState = SdkSessionStateEnum.NotLoaded; // Fallback: finalize the broken working group visually and do a full reload. lock(session.SessionEventLock) @@ -275,7 +261,7 @@ await sdkSession.SendAsync(new MessageOptions SessionIdleHandler.Handle(session, groupStatus: GroupStatusEnum.Error); } - session.MessagesSnapshot = [.. session.Messages]; + session.Conversation.PublishMessagesSnapshot(); } _sessionListFeature.NotifyStateChanged(); diff --git a/src/Cockpit/Features/Sessions/SessionFeature.Replay.cs b/src/Cockpit/Features/Sessions/SessionFeature.Replay.cs new file mode 100644 index 00000000..5df987f3 --- /dev/null +++ b/src/Cockpit/Features/Sessions/SessionFeature.Replay.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using Cockpit.Features.Agents.Models; +using Cockpit.Features.Canvas; +using Cockpit.Features.Git.Models; +using Cockpit.Features.Permissions; +using Cockpit.Features.SessionEvents; +using Cockpit.Features.SessionEvents.Models; +using Cockpit.Features.Sessions.Interactions; +using Cockpit.Features.Sessions.Models; +using Cockpit.Features.SystemMessage; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Microsoft.Extensions.Logging; +using SdkPlugin = GitHub.Copilot.Rpc.Plugin; +using SdkSessionMetadata = GitHub.Copilot.SessionMetadata; + +namespace Cockpit.Features.Sessions; + +public sealed partial class SessionFeature +{ + /// + /// Debug helper: clears the current session's messages and replays them from the SDK history, + /// introducing timestamp-proportional delays between events so the replay feels like a live session. + /// + public async Task ReplayCurrentSessionAsync(CancellationToken cancellationToken = default) + { + SessionModel? session = _sessionListFeature.CurrentSession; + if(session is null) + { + _logger.LogWarning("ReplayCurrentSession: no current session"); + return; + } + + if(!_sdkRegistry.TryGet(session.Id, out CopilotSession? sdkSession)) + { + _logger.LogWarning("ReplayCurrentSession: SDK session not found for {SessionId}", session.Id); + return; + } + + try + { + IReadOnlyList events = await sdkSession.GetEventsAsync(cancellationToken); + _logger.LogInformation("Replaying {Count} events for session {SessionId}", events.Count, session.Id); + + lock(session.SessionEventLock) + { + session.Conversation.ClearMessages(); + session.ActiveWorkingGroup = null; + } + _sessionListFeature.NotifyStateChanged(); + + // Same parentId-based immediate-mode detection used during live sessions applies + // here — no pre-processing or reordering needed. + Task streamCallback(ChatMessageModel msg, string text) => SessionEventHelpers.StreamSummaryTextAsync(msg, text, _sessionListFeature.NotifyStateChanged); + + DateTimeOffset? prevTimestamp = null; + foreach(SessionEvent evt in events) + { + cancellationToken.ThrowIfCancellationRequested(); + + if(prevTimestamp.HasValue) + { + TimeSpan realGap = evt.Timestamp - prevTimestamp.Value; + int delayMs = (int)Math.Clamp(realGap.TotalMilliseconds, 50, 3000); + await Task.Delay(delayMs, cancellationToken); + } + + lock(session.SessionEventLock) + { + _processor.Process(session, evt, streamCallback); + session.Conversation.PublishMessagesSnapshot(); + } + _sessionListFeature.NotifyStateChanged(); + + prevTimestamp = evt.Timestamp; + } + + lock(session.SessionEventLock) + { + if(session.ActiveWorkingGroup is not null) + { + _processor.FinalizeOpenGroup(session); + } + session.Conversation.PublishMessagesSnapshot(); + } + _sessionListFeature.NotifyStateChanged(); + + _logger.LogInformation("Replay complete for session {SessionId} — {MessageCount} messages", session.Id, session.Messages.Count); + } + catch(OperationCanceledException) + { + _logger.LogInformation("Replay cancelled for session {SessionId}", session.Id); + } + catch(Exception ex) + { + _logger.LogError(ex, "Replay failed for session {SessionId}", session.Id); + } + } + +} diff --git a/src/Cockpit/Features/Sessions/SessionFeature.SdkConfiguration.cs b/src/Cockpit/Features/Sessions/SessionFeature.SdkConfiguration.cs new file mode 100644 index 00000000..77fdb4bc --- /dev/null +++ b/src/Cockpit/Features/Sessions/SessionFeature.SdkConfiguration.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using Cockpit.Features.Agents.Models; +using Cockpit.Features.Canvas; +using Cockpit.Features.Git.Models; +using Cockpit.Features.Permissions; +using Cockpit.Features.SessionEvents; +using Cockpit.Features.SessionEvents.Models; +using Cockpit.Features.Sessions.Interactions; +using Cockpit.Features.Sessions.Models; +using Cockpit.Features.SystemMessage; +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Microsoft.Extensions.Logging; +using SdkPlugin = GitHub.Copilot.Rpc.Plugin; +using SdkSessionMetadata = GitHub.Copilot.SessionMetadata; + +namespace Cockpit.Features.Sessions; + +public sealed partial class SessionFeature +{ + void ApplySystemMessageCustomization(SessionConfig config, ModelInfo model) + => config.SystemMessage = CreateSystemMessageConfig(model.Name, model.Id); + + void ApplySystemMessageCustomization(ResumeSessionConfig config, ModelInfo model) + => config.SystemMessage = CreateSystemMessageConfig(model.Name, model.Id); + + void ApplySystemMessageCustomization(SessionConfig config, ModelInfo? model, string fallbackModelId) + => config.SystemMessage = CreateSystemMessageConfig(model?.Name ?? fallbackModelId, model?.Id ?? fallbackModelId); + + void ApplySystemMessageCustomization(ResumeSessionConfig config, ModelInfo? model, string fallbackModelId) + => config.SystemMessage = CreateSystemMessageConfig(model?.Name ?? fallbackModelId, model?.Id ?? fallbackModelId); + + SystemMessageConfig CreateSystemMessageConfig(string modelName, string modelId) + { + Dictionary sections = []; + foreach(KeyValuePair kvp in _appSettingsFeature.SystemMessageSectionOverrides) + { + SectionOverride? sectionOverride = MapToSectionOverride(kvp.Value); + if(sectionOverride is not null) + { + sections[new SystemMessageSection(kvp.Key)] = sectionOverride; + } + } + + sections[SystemMessageSection.Identity] = new SectionOverride() + { + Action = SectionOverrideAction.Replace, + Content = $""" + You are Cockpit, a desktop application that provides a graphical interface for GitHub Copilot CLI. You are an interactive AI assistant that helps users with software engineering tasks through a desktop GUI experience. + + # Search and delegation + + * When prompting sub-agents, provide comprehensive context — brevity rules do not apply to sub-agent prompts. + * When searching the file system for files or text, stay in the current working directory or child directories of the cwd unless absolutely necessary. + * When searching code, the preference order for tools to use is: code intelligence tools (if available) > LSP-based tools (if available) > glob > rg with glob pattern > powershell tool. + + Powered by . + + When asked who you are, reply with something like: "I'm Cockpit, a desktop application that provides a graphical interface for GitHub Copilot CLI, powered by GPT-5.4 mini (model ID: gpt-5.4-mini)." + + When asked which model is being used, reply with something like: "I'm powered by {modelName} (model ID: {modelId})." + + If the model was changed during the conversation, acknowledge the change and respond accordingly. + + Your job is to perform the task the user requested while providing a desktop-first experience through Cockpit. + """ + }; + + return new SystemMessageConfig + { + Mode = SystemMessageMode.Customize, + Sections = sections + }; + } +} diff --git a/src/Cockpit/Features/Sessions/SessionFeature.cs b/src/Cockpit/Features/Sessions/SessionFeature.cs index 5ca2238b..85222fc9 100644 --- a/src/Cockpit/Features/Sessions/SessionFeature.cs +++ b/src/Cockpit/Features/Sessions/SessionFeature.cs @@ -15,6 +15,7 @@ using Cockpit.Features.SessionEvents; using Cockpit.Features.SessionEvents.Models; using Cockpit.Features.Sessions.Models; +using Cockpit.Features.Sessions.Interactions; using Cockpit.Features.Skills; using Cockpit.Features.Terminal; using Cockpit.Features.UserInputRequests; @@ -47,6 +48,7 @@ public sealed partial class SessionFeature : IDisposable readonly IAppSettingsFeature _appSettingsFeature; readonly SessionHooksFactory _hooksFactory; readonly CanvasWindowManager _canvasWindowManager; + readonly SessionInteractionCoordinator _interactionCoordinator; public SessionFeature( CopilotClientFeature clientFeature, @@ -70,7 +72,8 @@ public SessionFeature( PluginsFeature pluginsFeature, IAppSettingsFeature appSettingsFeature, SessionHooksFactory hooksFactory, - CanvasWindowManager canvasWindowManager) + CanvasWindowManager canvasWindowManager, + SessionInteractionCoordinator interactionCoordinator) { _clientFeature = clientFeature; _logger = logger; @@ -94,6 +97,7 @@ public SessionFeature( _appSettingsFeature = appSettingsFeature; _hooksFactory = hooksFactory; _canvasWindowManager = canvasWindowManager; + _interactionCoordinator = interactionCoordinator; _clientFeature.OnConnectionStateChanged += HandleConnectionStateChanged; StartEvictionLoop(); @@ -118,9 +122,9 @@ public event Action? OnStateChanged add => _sessionListFeature.OnStateChanged += value; remove => _sessionListFeature.OnStateChanged -= value; } - public ActivityGroupModel? ActiveWorkingGroup => CurrentSession?.ActiveWorkingGroup; - public bool IsWorking => CurrentSession?.Status == SessionStatusEnum.Running - || (CurrentSession?.ActiveWorkingGroup is not null && CurrentSession.ActiveWorkingGroup.Status == GroupStatusEnum.Running); + public ActivityGroupModel? ActiveWorkingGroup => CurrentSession?.Conversation.ActiveWorkingGroup; + public bool IsWorking => CurrentSession?.Lifecycle.AgentRunState == AgentRunStateEnum.Running + || CurrentSession?.Conversation.ActiveWorkingGroup?.Status == GroupStatusEnum.Running; void HandleSessionEvent(string sessionId, SessionEvent evt) { @@ -135,10 +139,10 @@ void HandleSessionEvent(string sessionId, SessionEvent evt) ? (msg, text) => SessionEventHelpers.StreamSummaryTextAsync(msg, text, _sessionListFeature.NotifyStateChanged) : null; - lock(session.SessionEventLock) + lock(session.Conversation.SyncRoot) { _processor.Process(session, evt, streamCallback); - session.MessagesSnapshot = [.. session.Messages]; + session.Conversation.PublishMessagesSnapshot(); } if(session == _sessionListFeature.CurrentSession) diff --git a/src/Cockpit/Features/Updates/UpdateFeature.cs b/src/Cockpit/Features/Updates/UpdateFeature.cs index 96c6a08e..6d143b0b 100644 --- a/src/Cockpit/Features/Updates/UpdateFeature.cs +++ b/src/Cockpit/Features/Updates/UpdateFeature.cs @@ -621,12 +621,9 @@ internal static bool IsInstalledPath(string? executablePath, string? installDire } } - internal static bool IsSessionActive(SessionStatusEnum status) + internal static bool IsSessionActive(AgentRunStateEnum runState) { - return status is SessionStatusEnum.Running - or SessionStatusEnum.NeedsPermission - or SessionStatusEnum.NeedsUserInput - or SessionStatusEnum.NeedsElicitation; + return runState is AgentRunStateEnum.Running; } public void OpenReleaseInBrowser(GitHubReleaseModel release) @@ -690,7 +687,7 @@ bool HasActiveSessions() return false; } - return _sessionStateProvider.Sessions.Any(s => IsSessionActive(s.Status)); + return _sessionStateProvider.Sessions.Any(s => IsSessionActive(s.Lifecycle.AgentRunState)); } void SetDownloadFailed(string errorMessage) diff --git a/src/Cockpit/Features/UserInputRequests/UserInputFeature.cs b/src/Cockpit/Features/UserInputRequests/UserInputFeature.cs index 30239652..f8c5315a 100644 --- a/src/Cockpit/Features/UserInputRequests/UserInputFeature.cs +++ b/src/Cockpit/Features/UserInputRequests/UserInputFeature.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using Cockpit.Extensions; using Cockpit.Features.Sessions; +using Cockpit.Features.Sessions.Interactions; using Cockpit.Features.Sessions.Models; using GitHub.Copilot; using Microsoft.Extensions.Logging; @@ -13,6 +14,7 @@ namespace Cockpit.Features.UserInputRequests; public sealed class UserInputFeature : IUserInputHandler, IUserInputEventSource { readonly ISessionStateProvider _sessionStateProvider; + readonly SessionInteractionCoordinator _interactionCoordinator; readonly ILogger _logger; // In-memory cache of pending requests @@ -22,9 +24,13 @@ public sealed class UserInputFeature : IUserInputHandler, IUserInputEventSource public event Action? OnUserInputRequested; public event Action? OnUserInputResolved; - public UserInputFeature(ISessionStateProvider sessionStateProvider, ILogger logger) + public UserInputFeature( + ISessionStateProvider sessionStateProvider, + ILogger logger, + SessionInteractionCoordinator? interactionCoordinator = null) { _sessionStateProvider = sessionStateProvider; + _interactionCoordinator = interactionCoordinator ?? new SessionInteractionCoordinator(sessionStateProvider); _logger = logger; } @@ -85,7 +91,7 @@ public async Task HandleUserInputRequest(UserInputRequest req try { // Notify UI — inside try so cleanup always runs even if a subscriber throws - UpdateSessionOnUserInputRequested(request.SessionId, request); + _interactionCoordinator.AddUserInput(request.SessionId, request); try { @@ -110,62 +116,6 @@ public async Task HandleUserInputRequest(UserInputRequest req } } - void UpdateSessionOnUserInputRequested(string sessionId, UserInputRequestModel request) - { - SessionModel? session = _sessionStateProvider.Sessions.FirstOrDefault(s => s.Id == sessionId); - if(session is null) - { - return; - } - - _logger.LogInformation("User input requested - Adding request ID: {RequestId} to session {SessionId}", request.Id, sessionId); - - lock(session.StatusHistoryLock) - { - if(!session.PendingUserInputRequests.TryAdd(request.Id, request)) - { - _logger.LogWarning("User input request {RequestId} already exists for session {SessionId}", request.Id, sessionId); - return; - } - - // Only push to history on the first blocking request (i.e. when not already in a blocking status). - // Subsequent concurrent requests see NeedsPermission/NeedsUserInput and skip the push, preventing duplicates. - if(session.Status is not SessionStatusEnum.NeedsPermission and not SessionStatusEnum.NeedsUserInput and not SessionStatusEnum.NeedsElicitation) - { - session.StatusHistory.Push(session.Status); - } - session.Status = SessionStatusEnum.NeedsUserInput; - } - - _sessionStateProvider.NotifyStateChanged(); - } - - void UpdateSessionOnUserInputResolved(string sessionId, string requestId) - { - SessionModel? session = _sessionStateProvider.Sessions.FirstOrDefault(s => s.Id == sessionId); - if(session is null) - { - return; - } - - _logger.LogInformation("User input resolved - Removing request ID: {RequestId} from session {SessionId}", requestId, sessionId); - - lock(session.StatusHistoryLock) - { - session.PendingUserInputRequests.TryRemove(requestId, out _); - - session.Status = session.PendingPermissionRequests.IsEmpty && session.PendingUserInputRequests.IsEmpty && session.PendingElicitationRequests.IsEmpty - ? session.StatusHistory.TryPop(out SessionStatusEnum prev) ? prev : SessionStatusEnum.Idle - : session.PendingPermissionRequests.IsEmpty - ? !session.PendingUserInputRequests.IsEmpty - ? SessionStatusEnum.NeedsUserInput - : SessionStatusEnum.NeedsElicitation - : SessionStatusEnum.NeedsPermission; - } - - _sessionStateProvider.NotifyStateChanged(); - } - /// /// Simulate a user input request for debugging/testing. /// @@ -242,7 +192,7 @@ public void ResolveUserInputRequest(string requestId, string? response) return; } - UpdateSessionOnUserInputResolved(request.SessionId, request.Id); + _interactionCoordinator.ResolveUserInput(request.SessionId, request.Id); try { diff --git a/src/Cockpit/MauiProgram.cs b/src/Cockpit/MauiProgram.cs index 9dc381c7..7096b55f 100644 --- a/src/Cockpit/MauiProgram.cs +++ b/src/Cockpit/MauiProgram.cs @@ -20,6 +20,7 @@ using Cockpit.Features.SessionEvents; using Cockpit.Features.FileSearch; using Cockpit.Features.Sessions; +using Cockpit.Features.Sessions.Interactions; using Cockpit.Features.Skills; using Cockpit.Features.Sounds; using Cockpit.Features.Splash; @@ -131,6 +132,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton();