Skip to content

Copilot SDK adapter silently drops unhandled SDK session events (no default arm; self-invoke bypasses framework tool loop) #1312

Description

@JoshuaRowePhantom

Summary

Copilot-SDK-backed chats silently drop any GitHub.Copilot.SDK session-event kind that is not one of the ~11 cases handled by CopilotSdkStreamAdapter.TranslateCopilotSdkSessionEvents. The switch statement has no default: arm, and the adapter's own doc-comment states that "Unrecognised event types are dropped." Because CopilotSdkChatClient implements ISelfInvokingToolChatClient, AgentFactory.WrapWithMiddleware deliberately bypasses both ToolResultSteeringMiddleware and the framework FunctionInvokingChatClient — so the adapter's translation is the only path by which any item can reach the transcript, history, or UI. Any SDK event kind the adapter does not explicitly translate is permanently invisible: it is never persisted, never rendered, and never surfaced to the user. The user-visible symptom is missing tool-call / tool-result / reasoning / background-task items that the model clearly acted on (e.g. an agent references a read_powershell call and the result of a running background task, yet neither the tool call, its result, nor the background-task lifecycle appear in the transcript).

Root Cause

Two mechanisms combine to produce silent, unrecoverable event loss for the Copilot-SDK provider:

1. Self-invoking clients bypass framework middleware

Phantom.Workspaces.Llm.Core\CopilotSdkChatClient.cs line 32 — the class implements ISelfInvokingToolChatClient:

public sealed class CopilotSdkChatClient : IChatClient, IAsyncDisposable, ISelfInvokingToolChatClient, SlashCommands.IModelSlashCommandClient

Phantom.Workspaces.Llm.Core\AgentFactory.cs lines 410–424 — WrapWithMiddleware short-circuits for self-invoking clients:

// Wraps the inner client with ToolResultSteeringMiddleware when a queue manager is provided.
// Never wraps self-invoking clients — they drive their own tool loop and GetStreamingResponseAsync
// is never re-called with FunctionResultContent, so the middleware would never inject anything;
// worse, delegating GetService would make the framework suppress its FunctionInvocationMiddleware.
private static ChatClientResult WrapWithMiddleware(
    (IChatClient client, string displayName) inner,
    AgentInputQueueManager? queueManager)
{
    if (queueManager is null
        || inner.client is ISelfInvokingToolChatClient
        || inner.client.GetService(typeof(ISelfInvokingToolChatClient)) is not null)
    {
        return new ChatClientResult(inner.client, inner.displayName);
    }

    return new ChatClientResult(
        new ToolResultSteeringMiddleware(inner.client, queueManager),
        inner.displayName);
}

AgentChat.cs correspondingly sets UseProvidedChatClientAsIs = true for self-invoking clients, so the framework does not attach its own FunctionInvokingChatClient wrapper either.

Consequence: the SDK adapter's translator is the sole producer of transcript items for Copilot-SDK-backed chats. Anything it fails to translate never reaches persistence or the UI.

2. The adapter switch has no default: arm

Phantom.Workspaces.Llm.Core\CopilotSdkStreamAdapter.cs lines 77–221 — the doc-comment at lines 77–84 explicitly states the drop policy:

/// <summary>
/// Translates raw Copilot SDK session events into <see cref="ChatResponseUpdate"/> items.
/// The stream completes normally on <see cref="SessionIdleEvent"/> and faults with
/// <see cref="InvalidOperationException"/> on <see cref="SessionErrorEvent"/>. Unrecognised
/// event types are dropped. Accepting a <see cref="ChannelReader{T}"/> keeps the method
/// testable without a live Copilot SDK session: tests write mock events to a channel and
/// observe the translated output directly.
/// </summary>
public static async IAsyncEnumerable<ChatResponseUpdate> TranslateCopilotSdkSessionEvents(
    ChannelReader<SessionEvent> events,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    ArgumentNullException.ThrowIfNull(events);

    await foreach (var sessionEvent in events.ReadAllAsync(cancellationToken).ConfigureAwait(false))
    {
        switch (sessionEvent)
        {
            case AssistantMessageDeltaEvent delta when ...:            // line 95
            case AssistantReasoningDeltaEvent reasoningDelta when ...: // line 103
            case ToolExecutionStartEvent toolStart:                    // line 113
            case ToolExecutionCompleteEvent toolComplete:              // line 121
            case SubagentStartedEvent started when ...:                // line 136
            case SubagentCompletedEvent completed when ...:            // line 156
            case SubagentFailedEvent failed when ...:                  // line 168
            case AssistantUsageEvent usage when ...:                   // line 184
            case SystemNotificationEvent notification when ...:        // line 192
            case SessionErrorEvent error:                              // line 204
            case SessionIdleEvent:                                     // line 208
            // NO default arm — anything else falls through silently.
        }
    }
}

That is the entire set: 11 event kinds handled, everything else silently dropped with no log, no telemetry, no diagnostic. Tool start/complete are mapped via CopilotToolEventMapper.MapToolStart / MapToolComplete into FunctionCallContent / FunctionResultContent, but any other tool-lifecycle event (background/async/deferred completions, cancellations, progress, etc.) has nowhere to go.

3. The non-streaming path mirrors the same limited set

Phantom.Workspaces.Llm.Core\CopilotSdkChatClient.cs lines 512–567 — GetResponseAsync subscribes to the session with a switch at lines 531–547 that only handles ToolExecutionStartEvent and ToolExecutionCompleteEvent, so any fix in the streaming adapter must be mirrored here or the non-streaming path will keep losing events:

using var subscription = session.On<SessionEvent>(sessionEvent =>
{
    switch (sessionEvent)
    {
        case ToolExecutionStartEvent toolStart:
            lock (toolEventLock) { toolCalls.Add(CopilotToolEventMapper.MapToolStart(toolStart)); }
            break;
        case ToolExecutionCompleteEvent toolComplete:
            lock (toolEventLock) { toolResults.Add(CopilotToolEventMapper.MapToolComplete(toolComplete)); }
            break;
    }
});

Contrast with non-Copilot providers

Providers whose clients do NOT implement ISelfInvokingToolChatClient (echo, github-models, ollama, openai, azure-openai) fall through to the second branch of WrapWithMiddleware and are wrapped with ToolResultSteeringMiddleware and the framework FunctionInvokingChatClient. That framework layer surfaces tool-call / tool-result content as first-class AIContent, which is why those providers reliably show tool items even when the underlying model output is terse. The Copilot-SDK provider has no equivalent safety net — it depends entirely on the adapter's exhaustive translation.

Affected Files

File Contribution
Phantom.Workspaces.Llm.Core/CopilotSdkStreamAdapter.cs The streaming translator with the switch-with-no-default that silently drops unrecognised SDK events.
Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs Declares ISelfInvokingToolChatClient (line 32) and duplicates the limited event switch in the non-streaming GetResponseAsync path (lines 531–547).
Phantom.Workspaces.Llm.Core/AgentFactory.cs WrapWithMiddleware (lines 410–424) short-circuits for self-invoking clients, so no framework tool-content middleware compensates for adapter gaps.
Phantom.Workspaces.Llm.Core/CopilotToolEventMapper.cs Maps ToolExecutionStart/Complete → FunctionCallContent/FunctionResultContent; would need extension for any additional tool-lifecycle event kinds we start translating.
docs/design/copilot-sdk-session-events.md Catalog of Copilot SDK session events that the adapter must be reconciled against.
docs/design/copilot-sdk-tool-events.md Catalog of Copilot SDK tool-lifecycle events that the adapter must be reconciled against.

Design / Fix

The fix has four parts. The exact enumeration of event kinds that need explicit mapping is intentionally deferred to a follow-up investigation that reconciles the design docs against the live GitHub.Copilot.SDK event set the CLI actually emits; the design docs here list the shape of the fix, not the final list of case arms.

(a) Add explicit case arms for currently-dropped SDK event kinds

Prioritise, at minimum:

  • Complete assistant-reasoning blocks — the AssistantReasoningDeltaEvent delta is translated, but the completed reasoning block event (e.g. AssistantReasoningEvent or equivalent) is not, so reasoning content that arrives as a single non-delta payload disappears. Map to TextReasoningContent.
  • Background / async / deferred tool-lifecycle events — anything the SDK emits for a tool call whose completion is deferred (e.g. background PowerShell shells) that is NOT exactly ToolExecutionStartEvent / ToolExecutionCompleteEvent. These almost certainly explain the user-visible symptom that motivated this bug (an agent references a read_powershell result and a running background task, but nothing appears in the transcript). Map to FunctionCallContent / FunctionResultContent via an extended CopilotToolEventMapper.
  • Session / turn lifecycle events the model or user might want visibility into: e.g. SessionStart / SessionResume, AssistantIntent, AssistantTurnStart, SessionTitleChanged, SessionModelChange, SessionModeChanged, SessionCompactionStart / SessionCompactionComplete, SessionTaskComplete, SessionInfo, SessionWarning, SessionScheduleCreated / SessionScheduleCancelled, SessionPlanChanged, SessionWorkspaceFileChanged, PendingMessagesModified, etc. Most should map to a system/informational TextContent (possibly tagged with a distinct ContentTypePropertyName) so they persist and render; a subset (e.g. compaction) may deserve their own AIContent type. The exact list is subject to the follow-up investigation.

(b) Add a default: arm that never silently drops

Even after (a), the SDK may add new event kinds in future versions. The default: arm must at minimum log the unrecognised event (with kind, agent id, and a truncated payload) at Warning level, and ideally surface it as a generic informational content update tagged with a "unknown-copilot-sdk-event" content type so nothing is invisibly lost. The current behaviour — silent, unlogged drop — must not be reachable.

(c) Mirror the additions in the non-streaming GetResponseAsync path

CopilotSdkChatClient.GetResponseAsync (lines 512–567) has its own tool-event switch (lines 531–547) that is independent of the streaming adapter. Any event kind added in (a) or (b) must be handled there too, or the non-streaming path will continue to drop the same events.

(d) Reconcile design docs against the live SDK event set

docs/design/copilot-sdk-session-events.md and docs/design/copilot-sdk-tool-events.md must be reconciled against the concrete GitHub.Copilot.SDK event types the CLI emits at runtime. The follow-up investigation should update both docs and use them as the checklist that drives (a) and (c).

Relationship to other issues

  • Related to Toolbar 'Running agents' brain pulsates unconditionally; should only pulsate when an agent is actively working #1305 (toolbar pulsating brain should pulsate only when an agent is actively working) and to the separately-diagnosed gap where a background/async task does not mark the chat as running. The running-state is scoped to the synchronous model-turn loop in AgentChat.cs (running item created ~line 1726, cleared in the finally at ~line 1858 via CompleteRunningItem → runningItemOperations.Remove). A dropped background-tool lifecycle event is plausibly a shared trigger: the same unmapped async-tool event that hides transcript items would also fail to keep the chat "running." This bug is scoped to the dropped-items problem; the running-state issue may be filed separately.

Expected Tests

Add to Phantom.Workspaces.Llm.Core.Tests\CopilotSdkStreamAdapterTests.cs (and, where applicable, non-streaming tests against CopilotSdkChatClient.GetResponseAsync). Naming follows the existing TranslateCopilotSdkSessionEvents_<Scenario>_<Outcome> PascalCase convention already used throughout that file.

Test Scenario Expected Outcome
TranslateCopilotSdkSessionEvents_UnknownEventKind_IsSurfacedNotDropped A SessionEvent subtype that is not in the current 11 handled cases is written to the channel. The event is not silently dropped: it is either logged at Warning level and/or emitted as a generic informational ChatResponseUpdate tagged with an "unknown-copilot-sdk-event" content type. Regression guard against the switch losing a default: arm.
TranslateCopilotSdkSessionEvents_ReasoningBlockEvent_EmitsReasoningContent A complete (non-delta) assistant-reasoning event is written to the channel. A single ChatResponseUpdate with role Assistant is emitted whose Contents contains a TextReasoningContent carrying the full reasoning payload, tagged with the originating AgentId.
TranslateCopilotSdkSessionEvents_BackgroundToolEvent_EmitsToolItem A background / async / deferred tool-lifecycle event (exact SDK type TBD in follow-up) is written to the channel. A ChatResponseUpdate is emitted whose Contents contains an appropriate FunctionCallContent or FunctionResultContent (via an extended CopilotToolEventMapper) so the background tool call and its eventual result appear in the transcript.
TranslateCopilotSdkSessionEvents_UnknownEventKind_IsLoggedWithKindAndAgentId A SessionEvent subtype that is not in the current 11 handled cases is written to the channel with a non-null AgentId. The logger receives a Warning-level entry that includes both the runtime event type name and the AgentId, so future SDK additions are diagnosable from logs.
CopilotSdkChatClient_GetResponseAsync_BackgroundToolEvent_IncludedInResponse Non-streaming path: the session raises a background/async tool-lifecycle event during a turn. The returned ChatResponse includes the corresponding FunctionCallContent / FunctionResultContent items, mirroring the streaming adapter's behaviour.
CopilotSdkChatClient_GetResponseAsync_UnknownEventKind_IsSurfacedNotDropped Non-streaming path: the session raises an event kind not handled by the switch at lines 531–547. The event is logged and/or captured into the response rather than being silently discarded, matching the streaming adapter's default-arm behaviour.

The exact SDK event types referenced by these tests will be finalised as part of the follow-up investigation that reconciles copilot-sdk-session-events.md and copilot-sdk-tool-events.md against the live GitHub.Copilot.SDK event catalog.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiedverified-locallyImplementation has been verified locally

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions