Skip to content

Commit 23626b9

Browse files
Phase 4f: generic OnLifecycle<T> + On<T>, polymorphic lifecycle events
- Add SessionCreatedEvent / SessionDeletedEvent / SessionUpdatedEvent / SessionForegroundEvent / SessionBackgroundEvent as sealed derived types of SessionLifecycleEvent. Unknown future event types deserialize to the base SessionLifecycleEvent for forward compatibility (consumers can still inspect Type). - Remove the SessionLifecycleEventTypes static constants class (subsumed by the typed hierarchy). - Replace CopilotClient.On / CopilotClient.On(string, ...) with a single generic OnLifecycle<T>(Action<T>) where T : SessionLifecycleEvent. Consumers pass a derived type to filter, or SessionLifecycleEvent to receive all. - Replace CopilotSession.On(SessionEventHandler) with generic On<T>(Action<T>) where T : SessionEvent. Same filtering pattern. - Remove the SessionEventHandler delegate; SessionConfig.OnEvent / ResumeSessionConfig.OnEvent are now Action<SessionEvent>?. - Update samples, tests, and README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c636349 commit 23626b9

24 files changed

Lines changed: 160 additions & 184 deletions

dotnet/README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ await using var session = await client.CreateSessionAsync(new SessionConfig
4343
// Wait for the response using the session.idle event
4444
var done = new TaskCompletionSource();
4545

46-
session.On(evt =>
46+
session.On<SessionEvent>(evt =>
4747
{
4848
if (evt is AssistantMessageEvent msg)
4949
{
@@ -158,7 +158,7 @@ Request the TUI to switch to displaying the specified session. Only available in
158158
Subscribe to all session lifecycle events. Returns an `IDisposable` that unsubscribes when disposed.
159159

160160
```csharp
161-
using var subscription = client.On(evt =>
161+
using var subscription = client.OnLifecycle<SessionLifecycleEvent>(evt =>
162162
{
163163
Console.WriteLine($"Session {evt.SessionId}: {evt.Type}");
164164
});
@@ -169,7 +169,7 @@ using var subscription = client.On(evt =>
169169
Subscribe to a specific lifecycle event type. Use `SessionLifecycleEventTypes` constants.
170170

171171
```csharp
172-
using var subscription = client.On(SessionLifecycleEventTypes.Foreground, evt =>
172+
using var subscription = client.OnLifecycle<SessionForegroundEvent>(evt =>
173173
{
174174
Console.WriteLine($"Session {evt.SessionId} is now in foreground");
175175
});
@@ -208,12 +208,12 @@ Send a message to the session.
208208

209209
Returns the message ID.
210210

211-
##### `On(SessionEventHandler handler): IDisposable`
211+
##### `On(Action<SessionEvent> handler): IDisposable`
212212

213213
Subscribe to session events. Returns a disposable to unsubscribe.
214214

215215
```csharp
216-
var subscription = session.On(evt =>
216+
var subscription = session.On<SessionEvent>(evt =>
217217
{
218218
Console.WriteLine($"Event: {evt.Type}");
219219
});
@@ -262,7 +262,7 @@ Sessions emit various events during processing. Each event type is a class that
262262
Use pattern matching to handle specific event types:
263263

264264
```csharp
265-
session.On(evt =>
265+
session.On<SessionEvent>(evt =>
266266
{
267267
switch (evt)
268268
{
@@ -330,7 +330,7 @@ var session = await client.CreateSessionAsync(new SessionConfig
330330
// Use TaskCompletionSource to wait for completion
331331
var done = new TaskCompletionSource();
332332

333-
session.On(evt =>
333+
session.On<SessionEvent>(evt =>
334334
{
335335
switch (evt)
336336
{

dotnet/samples/Chat.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
OnPermissionRequest = PermissionHandler.ApproveAll
99
});
1010

11-
using var _ = session.On(evt =>
11+
using var _ = session.On<SessionEvent>(evt =>
1212
{
1313
Console.ForegroundColor = ConsoleColor.Blue;
1414
switch (evt)

dotnet/samples/ManualToolResume.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ static async Task<T> WaitForEventAsync<T>(CopilotSession session, Func<T, bool>?
8080
{
8181
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
8282
IDisposable? subscription = null;
83-
subscription = session.On(evt =>
83+
subscription = session.On<SessionEvent>(evt =>
8484
{
8585
if (evt is T typed && (predicate?.Invoke(typed) ?? true))
8686
{

dotnet/src/Client.cs

Lines changed: 39 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ namespace GitHub.Copilot.SDK;
4141
/// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll, Model = "gpt-4" });
4242
///
4343
/// // Handle events
44-
/// using var subscription = session.On(evt =>
44+
/// using var subscription = session.On&lt;SessionEvent&gt;(evt =>
4545
/// {
4646
/// if (evt is AssistantMessageEvent assistantMessage)
4747
/// Console.WriteLine(assistantMessage.Data?.Content);
@@ -82,11 +82,12 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
8282
private List<ModelInfo>? _modelsCache;
8383
private readonly SemaphoreSlim _modelsCacheLock = new(1, 1);
8484
private readonly Func<CancellationToken, Task<IList<ModelInfo>>>? _onListModels;
85-
private readonly List<Action<SessionLifecycleEvent>> _lifecycleHandlers = [];
86-
private readonly Dictionary<string, List<Action<SessionLifecycleEvent>>> _typedLifecycleHandlers = [];
85+
private readonly List<LifecycleSubscription> _lifecycleHandlers = [];
8786
private readonly object _lifecycleHandlersLock = new();
8887
private ServerRpc? _serverRpc;
8988

89+
private sealed record LifecycleSubscription(Type EventType, Action<SessionLifecycleEvent> Handler);
90+
9091
/// <summary>
9192
/// Gets the typed RPC client for server-scoped methods (no session required).
9293
/// </summary>
@@ -584,7 +585,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
584585
}
585586
if (config.OnEvent != null)
586587
{
587-
session.On(config.OnEvent);
588+
session.On<SessionEvent>(config.OnEvent);
588589
}
589590
ConfigureSessionFsHandlers(session, config.CreateSessionFsProvider);
590591
RegisterSession(session);
@@ -742,7 +743,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
742743
}
743744
if (config.OnEvent != null)
744745
{
745-
session.On(config.OnEvent);
746+
session.On<SessionEvent>(config.OnEvent);
746747
}
747748
ConfigureSessionFsHandlers(session, config.CreateSessionFsProvider);
748749
RegisterSession(session);
@@ -1102,103 +1103,59 @@ public async Task SetForegroundSessionIdAsync(string sessionId, CancellationToke
11021103
}
11031104

11041105
/// <summary>
1105-
/// Subscribes to all session lifecycle events.
1106-
/// </summary>
1107-
/// <remarks>
1108-
/// Lifecycle events are emitted when sessions are created, deleted, updated,
1109-
/// or change foreground/background state (in TUI+server mode).
1110-
/// </remarks>
1111-
/// <param name="handler">A callback function that receives lifecycle events.</param>
1112-
/// <returns>An IDisposable that, when disposed, unsubscribes the handler.</returns>
1113-
/// <example>
1114-
/// <code>
1115-
/// using var subscription = client.On(evt =>
1116-
/// {
1117-
/// Console.WriteLine($"Session {evt.SessionId}: {evt.Type}");
1118-
/// });
1119-
/// </code>
1120-
/// </example>
1121-
public IDisposable On(Action<SessionLifecycleEvent> handler)
1122-
{
1123-
ArgumentNullException.ThrowIfNull(handler);
1124-
1125-
lock (_lifecycleHandlersLock)
1126-
{
1127-
_lifecycleHandlers.Add(handler);
1128-
}
1129-
1130-
return new ActionDisposable(() =>
1131-
{
1132-
lock (_lifecycleHandlersLock)
1133-
{
1134-
_lifecycleHandlers.Remove(handler);
1135-
}
1136-
});
1137-
}
1138-
1139-
/// <summary>
1140-
/// Subscribes to a specific session lifecycle event type.
1106+
/// Subscribes to session lifecycle events of a specific kind.
11411107
/// </summary>
1142-
/// <param name="eventType">The event type to listen for (use SessionLifecycleEventTypes constants).</param>
1143-
/// <param name="handler">A callback function that receives events of the specified type.</param>
1144-
/// <returns>An IDisposable that, when disposed, unsubscribes the handler.</returns>
1108+
/// <typeparam name="T">
1109+
/// The lifecycle event type to listen for. Pass a derived type such as
1110+
/// <see cref="SessionCreatedEvent"/> to filter by kind, or
1111+
/// <see cref="SessionLifecycleEvent"/> to receive every lifecycle event.
1112+
/// </typeparam>
1113+
/// <param name="handler">A callback invoked when a matching lifecycle event arrives.</param>
1114+
/// <returns>An <see cref="IDisposable"/> that, when disposed, unsubscribes the handler.</returns>
11451115
/// <example>
11461116
/// <code>
1147-
/// using var subscription = client.On(SessionLifecycleEventTypes.Foreground, evt =>
1117+
/// using var sub = client.OnLifecycle&lt;SessionForegroundEvent&gt;(evt =&gt;
11481118
/// {
11491119
/// Console.WriteLine($"Session {evt.SessionId} is now in foreground");
11501120
/// });
11511121
/// </code>
11521122
/// </example>
1153-
public IDisposable On(string eventType, Action<SessionLifecycleEvent> handler)
1123+
public IDisposable OnLifecycle<T>(Action<T> handler) where T : SessionLifecycleEvent
11541124
{
1155-
ArgumentNullException.ThrowIfNull(eventType);
11561125
ArgumentNullException.ThrowIfNull(handler);
11571126

1127+
var subscription = new LifecycleSubscription(typeof(T), evt => handler((T)evt));
1128+
11581129
lock (_lifecycleHandlersLock)
11591130
{
1160-
if (!_typedLifecycleHandlers.TryGetValue(eventType, out var handlers))
1161-
{
1162-
handlers = [];
1163-
_typedLifecycleHandlers[eventType] = handlers;
1164-
}
1165-
1166-
handlers.Add(handler);
1131+
_lifecycleHandlers.Add(subscription);
11671132
}
11681133

11691134
return new ActionDisposable(() =>
11701135
{
11711136
lock (_lifecycleHandlersLock)
11721137
{
1173-
if (_typedLifecycleHandlers.TryGetValue(eventType, out var handlers))
1174-
{
1175-
handlers.Remove(handler);
1176-
}
1138+
_lifecycleHandlers.Remove(subscription);
11771139
}
11781140
});
11791141
}
11801142

11811143
private void DispatchLifecycleEvent(SessionLifecycleEvent evt)
11821144
{
1183-
List<Action<SessionLifecycleEvent>> typedHandlers;
1184-
List<Action<SessionLifecycleEvent>> wildcardHandlers;
1185-
1145+
List<LifecycleSubscription> snapshot;
11861146
lock (_lifecycleHandlersLock)
11871147
{
1188-
typedHandlers = _typedLifecycleHandlers.TryGetValue(evt.Type, out var handlers)
1189-
? [.. handlers]
1190-
: [];
1191-
wildcardHandlers = [.. _lifecycleHandlers];
1148+
snapshot = [.. _lifecycleHandlers];
11921149
}
11931150

1194-
foreach (var handler in typedHandlers)
1151+
var eventType = evt.GetType();
1152+
foreach (var subscription in snapshot)
11951153
{
1196-
try { handler(evt); } catch { /* Ignore handler errors */ }
1197-
}
1198-
1199-
foreach (var handler in wildcardHandlers)
1200-
{
1201-
try { handler(evt); } catch { /* Ignore handler errors */ }
1154+
if (!subscription.EventType.IsAssignableFrom(eventType))
1155+
{
1156+
continue;
1157+
}
1158+
try { subscription.Handler(evt); } catch { /* Ignore handler errors */ }
12021159
}
12031160
}
12041161

@@ -1766,13 +1723,19 @@ public void OnSessionEvent(string sessionId, JsonElement? @event)
17661723

17671724
public void OnSessionLifecycle(string type, string sessionId, JsonElement? metadata)
17681725
{
1769-
var evt = new SessionLifecycleEvent
1726+
SessionLifecycleEvent evt = type switch
17701727
{
1771-
Type = type,
1772-
SessionId = sessionId
1728+
"session.created" => new SessionCreatedEvent(),
1729+
"session.deleted" => new SessionDeletedEvent(),
1730+
"session.updated" => new SessionUpdatedEvent(),
1731+
"session.foreground" => new SessionForegroundEvent(),
1732+
"session.background" => new SessionBackgroundEvent(),
1733+
_ => new SessionLifecycleEvent()
17731734
};
17741735

1775-
if (metadata != null)
1736+
evt.Type = type;
1737+
evt.SessionId = sessionId;
1738+
if (metadata is not null)
17761739
{
17771740
evt.Metadata = JsonSerializer.Deserialize(
17781741
metadata.Value.GetRawText(),

dotnet/src/Session.cs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ namespace GitHub.Copilot.SDK;
4141
/// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll, Model = "gpt-4" });
4242
///
4343
/// // Subscribe to events
44-
/// using var subscription = session.On(evt =>
44+
/// using var subscription = session.On&lt;SessionEvent&gt;(evt =&gt;
4545
/// {
4646
/// if (evt is AssistantMessageEvent assistantMessage)
4747
/// {
@@ -65,7 +65,9 @@ public sealed partial class CopilotSession : IAsyncDisposable
6565
private volatile ElicitationHandler? _elicitationHandler;
6666
private volatile ExitPlanModeHandler? _exitPlanModeHandler;
6767
private volatile AutoModeSwitchHandler? _autoModeSwitchHandler;
68-
private ImmutableArray<SessionEventHandler> _eventHandlers = ImmutableArray<SessionEventHandler>.Empty;
68+
private ImmutableArray<EventSubscription> _eventHandlers = ImmutableArray<EventSubscription>.Empty;
69+
70+
private sealed record EventSubscription(Type EventType, Action<SessionEvent> Handler);
6971

7072
private SessionHooks? _hooks;
7173
private readonly SemaphoreSlim _hooksLock = new(1, 1);
@@ -318,7 +320,7 @@ void Handler(SessionEvent evt)
318320
}
319321
}
320322

321-
using var subscription = On(Handler);
323+
using var subscription = On<SessionEvent>(Handler);
322324

323325
await SendAsync(options, cancellationToken);
324326

@@ -381,7 +383,7 @@ void Handler(SessionEvent evt)
381383
/// </remarks>
382384
/// <example>
383385
/// <code>
384-
/// using var subscription = session.On(evt =>
386+
/// using var subscription = session.On&lt;SessionEvent&gt;(evt =&gt;
385387
/// {
386388
/// switch (evt)
387389
/// {
@@ -394,16 +396,21 @@ void Handler(SessionEvent evt)
394396
/// }
395397
/// });
396398
///
399+
/// // Or filter to a specific event kind at compile time:
400+
/// using var sub2 = session.On&lt;AssistantMessageEvent&gt;(evt =&gt;
401+
/// Console.WriteLine(evt.Data?.Content));
402+
///
397403
/// // The handler is automatically unsubscribed when the subscription is disposed.
398404
/// </code>
399405
/// </example>
400-
public IDisposable On(SessionEventHandler handler)
406+
public IDisposable On<T>(Action<T> handler) where T : SessionEvent
401407
{
402408
ArgumentNullException.ThrowIfNull(handler);
403409
ThrowIfDisposed();
404410

405-
ImmutableInterlocked.Update(ref _eventHandlers, array => array.Add(handler));
406-
return new ActionDisposable(() => ImmutableInterlocked.Update(ref _eventHandlers, array => array.Remove(handler)));
411+
var subscription = new EventSubscription(typeof(T), evt => handler((T)evt));
412+
ImmutableInterlocked.Update(ref _eventHandlers, array => array.Add(subscription));
413+
return new ActionDisposable(() => ImmutableInterlocked.Update(ref _eventHandlers, array => array.Remove(subscription)));
407414
}
408415

409416
/// <summary>
@@ -438,11 +445,16 @@ private async Task ProcessEventsAsync()
438445
await foreach (var sessionEvent in _eventChannel.Reader.ReadAllAsync())
439446
{
440447
var dispatchTimestamp = Stopwatch.GetTimestamp();
441-
foreach (var handler in _eventHandlers)
448+
var eventType = sessionEvent.GetType();
449+
foreach (var subscription in _eventHandlers)
442450
{
451+
if (!subscription.EventType.IsAssignableFrom(eventType))
452+
{
453+
continue;
454+
}
443455
try
444456
{
445-
handler(sessionEvent);
457+
subscription.Handler(sessionEvent);
446458
}
447459
catch (Exception ex)
448460
{
@@ -1488,7 +1500,7 @@ await InvokeRpcAsync<object>(
14881500
GC.SuppressFinalize(this);
14891501
}
14901502

1491-
_eventHandlers = ImmutableInterlocked.InterlockedExchange(ref _eventHandlers, ImmutableArray<SessionEventHandler>.Empty);
1503+
_eventHandlers = ImmutableInterlocked.InterlockedExchange(ref _eventHandlers, ImmutableArray<EventSubscription>.Empty);
14921504
_toolHandlers.Clear();
14931505
_commandHandlers.Clear();
14941506

0 commit comments

Comments
 (0)