diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index ef7982cb..b080bcb5 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -59,6 +59,24 @@ public partial class CopilotClient : IDisposable, IAsyncDisposable private readonly int? _optionsPort; private readonly string? _optionsHost; + /// + /// Occurs when a new session is created. + /// + /// + /// Subscribe to this event to hook into session events globally. + /// The handler receives the newly created instance. + /// + public event Action? SessionCreated; + + /// + /// Occurs when a session is destroyed. + /// + /// + /// Subscribe to this event to perform cleanup when sessions end. + /// The handler receives the session ID of the destroyed session. + /// + public event Action? SessionDestroyed; + /// /// Creates a new instance of . /// @@ -362,6 +380,13 @@ public async Task CreateSessionAsync(SessionConfig? config = nul throw new InvalidOperationException($"Session {response.SessionId} already exists"); } + session.OnDisposed = (id) => + { + _sessions.TryRemove(id, out _); + SessionDestroyed?.Invoke(id); + }; + SessionCreated?.Invoke(session); + return session; } @@ -414,8 +439,23 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes session.RegisterPermissionHandler(config.OnPermissionRequest); } + // Clear OnDisposed on the old session to prevent it from firing SessionDestroyed + // if it gets disposed after being replaced + if (_sessions.TryGetValue(response.SessionId, out var oldSession)) + { + oldSession.OnDisposed = null; + } + // Replace any existing session entry to ensure new config (like permission handler) is used _sessions[response.SessionId] = session; + + session.OnDisposed = (id) => + { + _sessions.TryRemove(id, out _); + SessionDestroyed?.Invoke(id); + }; + SessionCreated?.Invoke(session); + return session; } diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index f1e47df8..dafd0923 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -64,6 +64,12 @@ public partial class CopilotSession : IAsyncDisposable /// public string? WorkspacePath { get; } + /// + /// Internal callback invoked when the session is disposed. + /// Used by CopilotClient to fire the SessionDestroyed event. + /// + internal Action? OnDisposed { get; set; } + /// /// Initializes a new instance of the class. /// @@ -431,6 +437,8 @@ await _rpc.InvokeWithCancellationAsync( { _permissionHandlerLock.Release(); } + + OnDisposed?.Invoke(SessionId); } private class OnDisposeCall(Action callback) : IDisposable diff --git a/dotnet/test/ClientTests.cs b/dotnet/test/ClientTests.cs index 23b0d9d9..862dd936 100644 --- a/dotnet/test/ClientTests.cs +++ b/dotnet/test/ClientTests.cs @@ -172,4 +172,90 @@ public async Task Should_List_Models_When_Authenticated() await client.ForceStopAsync(); } } + + [Fact] + public async Task Should_Fire_SessionCreated_When_Session_Is_Created() + { + using var client = new CopilotClient(new CopilotClientOptions { CliPath = _cliPath, UseStdio = true }); + + try + { + await client.StartAsync(); + + CopilotSession? createdSession = null; + client.SessionCreated += session => createdSession = session; + + var session = await client.CreateSessionAsync(); + + Assert.NotNull(createdSession); + Assert.Equal(session.SessionId, createdSession!.SessionId); + } + finally + { + await client.ForceStopAsync(); + } + } + + [Fact] + public async Task Should_Fire_SessionDestroyed_When_Session_Is_Disposed() + { + using var client = new CopilotClient(new CopilotClientOptions { CliPath = _cliPath, UseStdio = true }); + + try + { + await client.StartAsync(); + + string? destroyedSessionId = null; + client.SessionDestroyed += id => destroyedSessionId = id; + + var session = await client.CreateSessionAsync(); + var sessionId = session.SessionId; + + Assert.Null(destroyedSessionId); + + await session.DisposeAsync(); + + Assert.NotNull(destroyedSessionId); + Assert.Equal(sessionId, destroyedSessionId); + } + finally + { + await client.ForceStopAsync(); + } + } + + [Fact] + public async Task Should_Fire_Events_For_Multiple_Sessions() + { + using var client = new CopilotClient(new CopilotClientOptions { CliPath = _cliPath, UseStdio = true }); + + try + { + await client.StartAsync(); + + var createdIds = new List(); + var destroyedIds = new List(); + client.SessionCreated += session => createdIds.Add(session.SessionId); + client.SessionDestroyed += id => destroyedIds.Add(id); + + var session1 = await client.CreateSessionAsync(); + var session2 = await client.CreateSessionAsync(); + + Assert.Equal(2, createdIds.Count); + Assert.Contains(session1.SessionId, createdIds); + Assert.Contains(session2.SessionId, createdIds); + + await session1.DisposeAsync(); + Assert.Single(destroyedIds); + Assert.Equal(session1.SessionId, destroyedIds[0]); + + await session2.DisposeAsync(); + Assert.Equal(2, destroyedIds.Count); + Assert.Equal(session2.SessionId, destroyedIds[1]); + } + finally + { + await client.ForceStopAsync(); + } + } } diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index 845e604a..038bfc20 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -196,6 +196,47 @@ public async Task Should_Throw_Error_When_Resuming_Non_Existent_Session() Client.ResumeSessionAsync("non-existent-session-id")); } + [Fact] + public async Task Should_Fire_SessionCreated_When_Session_Is_Resumed() + { + var session1 = await Client.CreateSessionAsync(); + var sessionId = session1.SessionId; + + CopilotSession? resumedSession = null; + Client.SessionCreated += s => resumedSession = s; + + var session2 = await Client.ResumeSessionAsync(sessionId); + + Assert.NotNull(resumedSession); + Assert.Equal(sessionId, resumedSession!.SessionId); + Assert.Same(session2, resumedSession); + } + + [Fact] + public async Task Should_Not_Fire_SessionDestroyed_When_Old_Session_Is_Disposed_After_Resume() + { + var session1 = await Client.CreateSessionAsync(); + var sessionId = session1.SessionId; + + var destroyedIds = new List(); + Client.SessionDestroyed += id => destroyedIds.Add(id); + + // Resume creates a new session object for the same session ID + var session2 = await Client.ResumeSessionAsync(sessionId); + + // Disposing the old session object should NOT fire SessionDestroyed + // because session2 is now the active session for this ID + await session1.DisposeAsync(); + + Assert.Empty(destroyedIds); + + // Disposing the new session should fire SessionDestroyed + await session2.DisposeAsync(); + + Assert.Single(destroyedIds); + Assert.Equal(sessionId, destroyedIds[0]); + } + [Fact] public async Task Should_Abort_A_Session() {