Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@ public partial class CopilotClient : IDisposable, IAsyncDisposable
private readonly int? _optionsPort;
private readonly string? _optionsHost;

/// <summary>
/// Occurs when a new session is created.
/// </summary>
/// <remarks>
/// Subscribe to this event to hook into session events globally.
/// The handler receives the newly created <see cref="CopilotSession"/> instance.
/// </remarks>
public event Action<CopilotSession>? SessionCreated;

/// <summary>
/// Occurs when a session is destroyed.
/// </summary>
/// <remarks>
/// Subscribe to this event to perform cleanup when sessions end.
/// The handler receives the session ID of the destroyed session.
/// </remarks>
public event Action<string>? SessionDestroyed;

/// <summary>
/// Creates a new instance of <see cref="CopilotClient"/>.
/// </summary>
Expand Down Expand Up @@ -362,6 +380,13 @@ public async Task<CopilotSession> 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;
}

Expand Down Expand Up @@ -414,8 +439,23 @@ public async Task<CopilotSession> 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;
}

Expand Down
8 changes: 8 additions & 0 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ public partial class CopilotSession : IAsyncDisposable
/// </value>
public string? WorkspacePath { get; }

/// <summary>
/// Internal callback invoked when the session is disposed.
/// Used by CopilotClient to fire the SessionDestroyed event.
/// </summary>
internal Action<string>? OnDisposed { get; set; }

/// <summary>
/// Initializes a new instance of the <see cref="CopilotSession"/> class.
/// </summary>
Expand Down Expand Up @@ -431,6 +437,8 @@ await _rpc.InvokeWithCancellationAsync<object>(
{
_permissionHandlerLock.Release();
}

OnDisposed?.Invoke(SessionId);
}

private class OnDisposeCall(Action callback) : IDisposable
Expand Down
86 changes: 86 additions & 0 deletions dotnet/test/ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
var destroyedIds = new List<string>();
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();
}
}
}
41 changes: 41 additions & 0 deletions dotnet/test/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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()
{
Expand Down