Skip to content

SessionStartEvent and SessionShutdownEvent never fire via CopilotSession.On for .NET #535

@MattKotsenas

Description

@MattKotsenas

I ran into these two issues while trying to create my own OpenTelemetry instrumentor for copilot sessions on .NET

Related to #155, #181.

SessionStartEvent never fires in .On() registration

The event fires during CreateSessionAsync(), which prevents ever seeing it as part of a .On() registration. The event IS emitted via GetMessagesAsync(). This makes sense technically (the session was created before the registration happened), however it makes the API difficult to consume. It forces consumers to capture existing events and replay them for logging, and to do so in the right order and without double emits).

SessionShutdownEvent appears to never fire

I can't seem to get this one to fire at all, either from On() or from GetMessagesAsync(), even when forcing shutdown of the session while keeping the On subscription alive.

Repo

Here's a single file repro you can use to see what I'm seeing. Run with dotnet Program.cs with .NET 10:

#!/usr/bin/env dotnet
#:package GitHub.Copilot.SDK@0.1.26-preview.0

// Bug repro: SessionStartEvent and SessionShutdownEvent never fire
// when subscribing to CopilotSession events via session.On(...).
//
// This repro addresses potential timing/scoping objections:
//   1. Subscription is created immediately after CreateSessionAsync
//   2. Subscription is kept alive through session disposal (manual dispose order)
//   3. GetMessagesAsync checks the event history independently
//
// SDK version: GitHub.Copilot.SDK 0.1.26-preview.0

using GitHub.Copilot.SDK;

using var client = new CopilotClient();
await client.StartAsync(CancellationToken.None);

string workDir = Path.Combine(Path.GetTempPath(), $"sdk-model-repro-{Guid.NewGuid():N}");
Directory.CreateDirectory(workDir);

try
{
    var config = new SessionConfig
    {
        Model = "claude-sonnet-4",
        WorkingDirectory = workDir,
    };

    Console.WriteLine($"SessionConfig.Model = \"{config.Model}\"");
    Console.WriteLine();

    var receivedEvents = new List<string>();

    // Create session, then subscribe immediately (no gap for missed events)
    var session = await client.CreateSessionAsync(config);
    var sub = session.On(evt =>
    {
        receivedEvents.Add(evt.GetType().Name);

        switch (evt)
        {
            case SessionStartEvent start:
                Console.WriteLine($"[SessionStartEvent]");
                Console.WriteLine($"  SelectedModel = {Quote(start.Data?.SelectedModel)}");
                break;

            case AssistantTurnStartEvent:
                Console.WriteLine($"[AssistantTurnStartEvent]");
                break;

            case AssistantUsageEvent usage:
                Console.WriteLine($"[AssistantUsageEvent]");
                Console.WriteLine($"  Model         = {Quote(usage.Data?.Model)}");
                Console.WriteLine($"  InputTokens   = {usage.Data?.InputTokens}");
                Console.WriteLine($"  OutputTokens  = {usage.Data?.OutputTokens}");
                break;

            case AssistantTurnEndEvent:
                Console.WriteLine($"[AssistantTurnEndEvent]");
                break;

            case SessionShutdownEvent shutdown:
                Console.WriteLine($"[SessionShutdownEvent]");
                Console.WriteLine($"  CurrentModel  = {Quote(shutdown.Data?.CurrentModel)}");
                Console.WriteLine($"  ShutdownType  = {shutdown.Data?.ShutdownType}");
                break;
        }
    });

    Console.WriteLine("Sending prompt...");
    Console.WriteLine();

    try
    {
        await session.SendAndWaitAsync(
            new MessageOptions { Prompt = "Reply with exactly: hello" },
            timeout: TimeSpan.FromMinutes(2));
    }
    catch (Exception ex)
    {
        Console.WriteLine($"SendAndWaitAsync threw: {ex.Message}");
    }

    // Check event history independently of the subscription
    Console.WriteLine();
    Console.WriteLine("--- Event history (GetMessagesAsync) ---");
    var history = await session.GetMessagesAsync(CancellationToken.None);
    foreach (var evt in history)
        Console.WriteLine($"  {evt.GetType().Name}");

    // Dispose session while subscription is still active
    Console.WriteLine();
    Console.WriteLine("--- Disposing session (subscription still active) ---");
    await session.DisposeAsync();
    await Task.Delay(1000); // allow any async event delivery

    // Now dispose subscription
    sub.Dispose();

    // Diagnosis
    Console.WriteLine();
    Console.WriteLine("--- Diagnosis ---");

    var historyTypes = history.Select(e => e.GetType().Name).ToHashSet();

    Console.WriteLine($"SessionStartEvent    in history: {historyTypes.Contains("SessionStartEvent"),-5}  delivered to On(): {receivedEvents.Contains("SessionStartEvent")}");
    Console.WriteLine($"SessionShutdownEvent in history: {historyTypes.Contains("SessionShutdownEvent"),-5}  delivered to On(): {receivedEvents.Contains("SessionShutdownEvent")}");
}
finally
{
    try { Directory.Delete(workDir, recursive: true); } catch { }
}

static string Quote(string? value) => value is null ? "<null>" : $"\"{value}\"";

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions