Skip to content

Copilot SDK sub-agents still do not show updates incrementally — content collapses to a single foreground update at sub-agent completion (despite #1139 / #1154) #1175

Description

@JoshuaRowePhantom

Summary

Regression — fixed incidentally. Copilot-SDK sub-agent CHILD transcripts stopped rendering incrementally: assistant deltas, reasoning, and tool starts were arriving in one burst at (or near) sub-agent completion. The originally diagnosed mechanism was head-of-line blocking in the SDK dispatch loop introduced by 44bbe07d (Fix #1109) combined with an unbounded upstream eventChannel from 2c4b27e4 (Fix #765). Investigation of the code between in-use-2026-08-01 and current HEAD shows the visible symptom has been fixed incidentally by 32bd90c9 (Fix #1174 — "mark CopilotSubAgentChatClient as ISelfInvokingToolChatClient"), which removed the real buffering point that was collapsing streaming child deltas into a single completion-time burst: the FunctionInvokingChatClient middleware that the agent framework was wrapping around every hosted sub-agent chat client.

Cross-references: #1109 (introducer of factory/table routing path, MERGED), #1174 (incidental fix, MERGED), #1139 (AgentId keying + pendingChildSinksByToolCall; MERGED), #1154 (assert-before-.Complete() regression tests; MERGED, test-only).


Resolution — fixed incidentally by 32bd90c9 ("Fix #1174: mark CopilotSubAgentChatClient as ISelfInvokingToolChatClient")

The commit

Commit 32bd90c9 (Joshua Rowe, 2026-07-31) was authored to fix a different bug (#1174: the injected FunctionCallContent(Name="task") was hitting a name lookup in FunctionInvokingChatClient middleware and throwing Requested function "task" not found). The same code change removes the actual buffering that produced #1175's burst-at-completion symptom.

Key hunks:

Phantom.Workspaces.Llm.Core\CopilotSubAgentChatClient.cs — the hosted sub-agent stub now advertises ISelfInvokingToolChatClient on itself and via GetService:

// BEFORE
internal sealed class CopilotSubAgentChatClient
    : IChatClient, ICopilotSubAgentReceiver, IHostedAgentChatClient
{
    public object? GetService(Type serviceType, object? key = null) =>
        serviceType == typeof(ICopilotSubAgentReceiver) ? this : null;
}

// AFTER
internal sealed class CopilotSubAgentChatClient
    : IChatClient, ICopilotSubAgentReceiver, IHostedAgentChatClient, ISelfInvokingToolChatClient
{
    public object? GetService(Type serviceType, object? key = null)
    {
        if (serviceType == typeof(ICopilotSubAgentReceiver)) return this;
        if (serviceType == typeof(ISelfInvokingToolChatClient)) return this;
        return null;
    }
}

Phantom.Workspaces.Llm.Core\AgentChat.cs:2120-2134 — the pre-existing decision point that this marker feeds:

/// For self-invoking clients the middleware is both unnecessary and harmful — it buffers
/// streaming tool-call/result content so it would not stream live into the GUI.
internal static bool ResolveUseProvidedChatClientAsIs(bool hasClientOverride, IChatClient resolvedClient)
{
    ArgumentNullException.ThrowIfNull(resolvedClient);
    return hasClientOverride
        || resolvedClient is ISelfInvokingToolChatClient
        || resolvedClient.GetService(typeof(ISelfInvokingToolChatClient)) is not null;
}

Because ResolveUseProvidedChatClientAsIs now returns true for every hosted sub-agent, ChatClientAgentOptions.UseProvidedChatClientAsIs = true is set at agent construction (AgentChat.cs:270-327), and ChatClientAgent uses the CopilotSubAgentChatClient as-is with no FunctionInvokingChatClient wrapper.

Causal mechanism

The original diagnosis correctly identified that the visible symptom was "child deltas do not surface until sub-agent completion". It attributed the buffering to the SDK dispatch loop blocking on factory.CreateAsync. In practice, the dominant per-turn buffering was one layer downstream: the agent framework's FunctionInvokingChatClient middleware, which wraps a hosted IChatClient in order to intercept and invoke function calls, buffers streaming ChatResponseUpdates so it can pair FunctionCallContent with tool results within a turn. For a receive-only sub-agent stub the middleware never actually invokes anything, but it still holds back the streaming updates until the turn's natural break — producing exactly the "burst at completion" symptom on the child transcript.

By marking CopilotSubAgentChatClient as ISelfInvokingToolChatClient, commit 32bd90c9 causes ChatClientAgent to skip the middleware entirely for every hosted sub-agent (see the intent comment quoted verbatim above at AgentChat.cs:2120-2126). Child ChatResponseUpdates now flow directly from the router's ICopilotSubAgentReceiver.Push (which is TryWrite on the sub-agent's unbounded input channel — CopilotSubAgentChatClient.cs) through the hosted process loop (AgentChat.RunHostedProcessLoopAsync) and into PartialResponseConflator.Notify per delta, producing multiple CollectionChanged ticks over the lifetime of the child turn instead of one at the end.

Current-source confirmation

  • CopilotSubAgentChatClient : … ISelfInvokingToolChatClientPhantom.Workspaces.Llm.Core\CopilotSubAgentChatClient.cs:16 and GetService at :56-62.
  • ResolveUseProvidedChatClientAsIs returns true for ISelfInvokingToolChatClientPhantom.Workspaces.Llm.Core\AgentChat.cs:2128-2134.
  • The flag flows into ChatClientAgentOptions.UseProvidedChatClientAsIs at agent construction — Phantom.Workspaces.Llm.Core\AgentChat.cs:270-327 (with the test hook AgentChat.UseProvidedChatClientAsIs at :45-56).
  • Verified with git -C features log --oneline in-use-2026-08-01..HEAD -- Phantom.Workspaces.Llm.Core/CopilotSubAgentChatClient.cs Phantom.Workspaces.Llm.Core/CopilotSubAgentRouter.cs Phantom.Workspaces.Llm.Core/AgentChat.cs Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs: no other in-range commit modifies the streaming path in a way that would affect this symptom.

Root Cause (original diagnosis — retained for the record)

The original head-of-line-blocking diagnosis is preserved below because the mechanism it describes is still present in the current source and remains a legitimate residual concern — see Residual work. The blocking-at-CreateAsync mechanism was, however, not the primary cause of the observed burst-at-completion; the middleware buffering removed by 32bd90c9 was.

Regression — introducing commit(s)

Primary regressor (per original diagnosis): 44bbe07d — "Fix #1109: unify sub-agent routing on the factory/table path and make it mandatory" (Joshua Rowe, 2026-07-22)

  • Inline await factory.CreateAsync on the dispatch-loop pathCopilotSubAgentRouter.cs:290-297.
  • Inline await subAgentTable.Add(agentChat) on the same pathCopilotSubAgentRouter.cs:305.
  • ChildRoutingEntry.pending buffer + tight-foreach flush in AttachCopilotSubAgentRouter.cs:521-602.

Latent enabler: 2c4b27e4 — "Fix #765: Serialize CopilotSdkTurnEventDispatcher event dispatch" (Joshua Rowe, 2026-07-07)

Introduced the single-reader unbounded dispatch channel (CopilotSdkChatClient.cs:561) and await router.RouteAsync(update) per event (CopilotSdkChatClient.cs:566-580). Non-blocking TryWrite on an unbounded channel means any stall in RouteAsync produces unbounded upstream accumulation with no back-pressure.

The (still-present) blocking site

Phantom.Workspaces.Llm.Core\CopilotSdkChatClient.cs:561-580 — single-reader dispatch loop.

Phantom.Workspaces.Llm.Core\CopilotSubAgentRouter.cs:132RouteAsync dispatches lifecycle inline via await this.HandleSubAgentStartedAsync(start).

Phantom.Workspaces.Llm.Core\CopilotSubAgentRouter.cs:200-308HandleSubAgentStartedAsync still awaits factory.CreateAsync at :290-297 and subAgentTable.Add at :305 inline on the dispatch loop before entry.Attach at :307. ChildRoutingEntry.Attach at :562-602 still flushes pending in a tight foreach at :589-592.

This code path IS still capable of stalling the dispatch loop while the factory constructs a child chat, and any child deltas that arrive during the create window will still land in ChildRoutingEntry.pending and be delivered as one tight burst by Attach. In practice, since 32bd90c9, the child-chat construction is fast enough (and, importantly, the per-turn streaming that used to burst at completion is no longer buffered by middleware) that this no longer produces the observed symptom. See Residual work.


Affected Files

File Contribution
Phantom.Workspaces.Llm.Core\CopilotSubAgentChatClient.cs Fix landed here (32bd90c). Now implements ISelfInvokingToolChatClient (:16) and returns itself for that service via GetService (:56-62).
Phantom.Workspaces.Llm.Core\AgentChat.cs Consumes the marker: ResolveUseProvidedChatClientAsIs (:2128-2134) returns true for hosted sub-agents; the flag flows into ChatClientAgentOptions.UseProvidedChatClientAsIs (:270-327), so ChatClientAgent uses the sub-agent stub as-is with no FunctionInvokingChatClient wrapping. Comment at :2120-2126 documents the streaming rationale explicitly.
Phantom.Workspaces.Llm.Core\CopilotSubAgentRouter.cs Residual concern. HandleSubAgentStartedAsync (:200-308) still awaits factory.CreateAsync (:290-297) and subAgentTable.Add (:305) inline on the dispatch loop, and ChildRoutingEntry.Attach (:562-602) still flushes pending in a tight foreach. Not the cause of the observed burst symptom (that was middleware buffering), but the head-of-line block remains theoretically possible under a slow factory create.
Phantom.Workspaces.Llm.Core\CopilotSdkChatClient.cs Latent enabler retained. Single-reader dispatchLoop (:566-580) awaits router.RouteAsync per update; eventChannel (:561) is unbounded; SDK writes via non-blocking TryWrite (:564). No back-pressure remains.
Phantom.Workspaces.Llm.Core.Tests\CopilotSubAgentRouterTests.cs Should host the streaming regression test (see Expected Tests).
Phantom.Workspaces.Llm.Core.Tests\AgentChatHostedSubAgentTests.cs Should host the end-to-end streaming regression test asserting multiple CollectionChanged ticks over the child turn.

Design / Fix

Landed (32bd90c)

The fix is 32bd90c9. CopilotSubAgentChatClient implements ISelfInvokingToolChatClient; AgentChat.ResolveUseProvidedChatClientAsIs therefore returns true for every hosted sub-agent; ChatClientAgent skips FunctionInvokingChatClient wrapping; streaming child deltas flow through the receive-only stub without middleware buffering. No further code change is required to close #1175.

Residual work

The originally-diagnosed head-of-line block in HandleSubAgentStartedAsync (CopilotSubAgentRouter.cs:290-307) and the tight-foreach flush in ChildRoutingEntry.Attach (:589-592) are still present. They are no longer the cause of the observed symptom, but they remain a latent hazard: if factory.CreateAsync ever becomes slow again (e.g. adds network I/O, foreground-scheduler hops, or heavy composition), the dispatch loop will stall and child deltas will re-accumulate in pending. The Residual-Hardening test below is intended to fence that hazard so the visible symptom cannot silently reappear. Optional follow-up (tracked separately if pursued): move factory.CreateAsync / subAgentTable.Add / lease disposal off the dispatch loop (e.g. via Task.Run), and flush pending with per-item Task.Yield() continuations. Not required to close this issue.

Considered / Background (superseded / rejected)

  • "Fix 1: move factory.CreateAsync / subAgentTable.Add / lease DisposeAsync off the dispatch loop via Task.Run." Originally proposed as the primary fix. Overtaken by 32bd90c9: the observed symptom was actually middleware buffering, not dispatch-loop blocking. Kept here as background because the underlying head-of-line hazard still exists; may be revisited if it re-surfaces.
  • "Fix 2: flush ChildRoutingEntry.pending with per-item yielding." Same status — no longer needed for Copilot SDK sub-agents still do not show updates incrementally — content collapses to a single foreground update at sub-agent completion (despite #1139 / #1154) #1175, may still be worth doing for hardening.
  • Give hosted sub-agent chats a dedicated foregroundScheduler distinct from the parent's. Owner rejected: "I don't think this is a UI scheduling issue." Confirmed — events do not reach any scheduler during the middleware buffering; the fix was upstream of any scheduler.
  • "Fix A": post-and-forget the intermediate frames in PartialResponseConflator.RunWorkerAsync. Rejected: downstream of the real bug; cannot un-collapse a burst already coalesced by the conflator.
  • "Fix B": promote sub-agent items on tool-call / reasoning boundaries via stableCount = Length - 1. Already in place — AgentChat.cs promotion logic.
  • "Fix C": work around Copilot SDK sub-agent batching in the adapter. No batching to work around — CopilotSdkStreamAdapter.TranslateCopilotSdkSessionEvents yields per-event.
  • Periodic UI-thread refresh / completion-gated re-render. Rejected as masking fixes.
  • Revert 44bbe07d (Sub-agent chat view renders text that belongs to the parent agent #1109) wholesale. Rejected — that commit fixes dropped/leaked child content and cannot simply be undone.

Expected Tests

The tests below are intended (a) to guard against the burst-at-completion symptom reappearing, and (b) to fence the residual dispatch-loop hazard so a future slow factory.CreateAsync cannot silently regress the streaming property.

Test Name Class What It Verifies
CopilotSubAgentChatClient_ExposesSelfInvokingToolChatClientMarker_SoAgentFrameworkSkipsFunctionInvokingMiddleware CopilotSubAgentChatClientTests Regression fence for 32bd90c9. Asserts CopilotSubAgentChatClient implements ISelfInvokingToolChatClient and returns itself from GetService(typeof(ISelfInvokingToolChatClient)) (survives DelegatingChatClient propagation). Guards against inadvertent removal of the marker.
AgentChat_HostedSubAgent_UseProvidedChatClientAsIsIsTrue_NoFunctionInvokingMiddlewareWrapping AgentChatHostedSubAgentTests Uses the internal AgentChat.UseProvidedChatClientAsIs test hook (AgentChat.cs:45-56) to assert every hosted sub-agent chat is constructed with ChatClientAgentOptions.UseProvidedChatClientAsIs = true. Confirms ResolveUseProvidedChatClientAsIs still returns true for the sub-agent code path.
SubAgentStreaming_ChildDeltasArriveIncrementally_NotBatchedAtCompletion AgentChatHostedSubAgentTests Primary regression guard for #1175. Drives a hosted sub-agent through several AssistantMessageDeltaEvent / ToolExecutionStartEvent updates followed by SubagentCompletedEvent. Subscribes a CollectionChanged counter on the child chat's RunningItems[0].Items. Asserts more than one CollectionChanged notification fires before the sub-agent lifecycle completes — proving child streaming is not batched at completion.
SubAgentStreaming_SlowFactoryCreate_DispatchLoopDoesNotAccumulateChildDeltas CopilotSubAgentRouterTests Residual-hazard fence. Injects an IRunningAgentChatFactory.CreateAsync that awaits an externally-controlled TaskCompletionSource; concurrently pushes several child RouteAsync deltas. Asserts each delta is either observed by the child receiver before the factory completes, OR — if buffered — is flushed with per-item yields (not one tight burst) after Attach. Fails loudly if HandleSubAgentStartedAsync re-introduces a slow synchronous factory call and pending collects all deltas.

Naming follows the codebase's Subject_Scenario_ExpectedOutcome convention.


References

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdiagnosedRoot cause identifiedwontfixThis will not be worked on

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions