Skip to content
2 changes: 1 addition & 1 deletion Tests/Cockpit.Benchmarks/SessionLoadingBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
Context = new SdkSessionContext
{
// Mix of resolvable paths and null to exercise both normalization branches.
WorkingDirectory = i % 2 == 0 ? validDir : null,

Check warning on line 66 in Tests/Cockpit.Benchmarks/SessionLoadingBenchmarks.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference assignment.

Check warning on line 66 in Tests/Cockpit.Benchmarks/SessionLoadingBenchmarks.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference assignment.
GitRoot = i % 2 == 0 ? validDir : null,
Repository = i % 2 == 0 ? "owner/repo" : null,
Branch = i % 2 == 0 ? "main" : null
Expand Down Expand Up @@ -101,7 +101,7 @@
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = "",
Expand Down Expand Up @@ -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<ElicitationResult> handleTask, ElicitationRequestModel model) = await StartHandleAsync(
feature, BuildContext());
Expand All @@ -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<ElicitationResult> handleTask, ElicitationRequestModel model) = await StartHandleAsync(
feature, BuildContext());
Expand All @@ -167,12 +168,12 @@ public async Task HandleElicitationRequest_PendingRequestAddedAndRemovedFromSess
(Task<ElicitationResult> 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]
Expand Down Expand Up @@ -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<ElicitationResult> task1, ElicitationRequestModel model1) = await StartHandleAsync(feature, BuildContext(message: "First"));
(Task<ElicitationResult> 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);
Expand All @@ -225,18 +226,21 @@ public async Task HandleElicitationRequest_SecondConcurrentRequestDoesNotPushSta

// All resolved — restored to Running
session.Status.ShouldBe(SessionStatusEnum.Running);
session.AgentRunState.ShouldBe(AgentRunStateEnum.Running);
}

// ── Priority resolution ───────────────────────────────────────────────────

[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"],
Expand All @@ -245,7 +249,7 @@ public async Task OnElicitationResolve_NeedsPermissionPrioritisedOverElicitation
CanApproveGlobally = true,
CanApproveForSession = true,
FullRequestJson = "{}"
};
});

(Task<ElicitationResult> handleTask, ElicitationRequestModel model) = await StartHandleAsync(
feature, BuildContext());
Expand All @@ -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<ElicitationResult> handleTask, ElicitationRequestModel model) = await StartHandleAsync(
feature, BuildContext());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlobalPermissionFeature>.Instance, testFile);
TestSessionStateProvider stateProvider = new();
Expand All @@ -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 = "",
Expand Down Expand Up @@ -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 = "",
Expand Down Expand Up @@ -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 = "",
Expand Down Expand Up @@ -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 = "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ChatMessageModel> 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<ChatMessageModel> 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();
}
}
Loading
Loading