Repository navigation
Sub-agent output appears in the parent chat view instead of the sub-agent view #1110
Description
Part of #1102.
Related: #1109 (shared root cause ΓÇö the inverse-facing symptom; both are resolved together by the single unified routing-path rewrite). See also sub-agent siblings #1108, #1111, #1112.
Summary
When a Copilot SDK sub-agent runs, the sub-agent's own streamed output is missing from the sub-agent's chat transcript and instead appears in the parent agent's transcript. The sub-agent card looks empty/stale while the parent view fills with text the parent never generated. This is the inverse face of #1109 (parent text rendered in the sub-agent view).
Both are two faces of a single structural defect: sub-agent turn-event routing is implemented as TWO divergent code paths selected purely by factory != null, which resolve targets, register child sinks, and marshal updates differently. When a sub-agent's content loses its agentId tag, or arrives before the child receiver is registered, routing falls through to the root (parent) sink ΓÇö so the child's text renders under the parent. Per the owner directive on #1109 ("There should be only one 'path'"), the fix is to collapse the two paths into one deterministic routing path; that single-path rewrite resolves this issue and #1109 together.
Root Cause
This is the same defect as #1109 ΓÇö see that issue for the full path-by-path analysis and code excerpts. In brief:
CopilotSubAgentRouter (Phantom.Workspaces.Llm.Core\CopilotSubAgentRouter.cs) branches on this.factory in four places (162ΓÇô195, 197ΓÇô241, 246ΓÇô270) to select between a registry path and a factory path. The factory path (PushUpdate, 246ΓÇô262) drops content for an agentId not yet in factoryReceivers; both paths fall back to the parent sink for null/unknown agentId (GetTarget, 267ΓÇô270). Child receivers are registered only after an async await chain (HandleSubAgentStartedWithFactoryAsync, 272ΓÇô286; factoryReceivers[agentId] set at 285), so child content emitted during that window is lost to the parent.
Attribution is a conditionally-present per-content tag: CopilotSdkStreamAdapter.Tag (222ΓÇô231) stamps AgentId only when non-empty, and the SDK sometimes omits it (TryGetSubAgentId, 214ΓÇô220). Untagged sub-agent content therefore returns null from GetParentToolCallId (195ΓÇô201) and is routed to root.
Regression: commit e5c559b5 ("Fix #1036", 2026-07-15) made AgentChatFactory.WithSelfAsFactory (AgentChatFactory.cs:243–246, applied at 78/154/213) stamp a non-null RunningAgentChatFactory into every chat, flipping production from the #913-fixed registry path onto the factory path (via AgentChat.cs:260 → CopilotSdkChatClient.SetSubAgentDependencies, 438–442 → router construction, 532–537). Known-good 826827ab; git bisect converges on e5c559b5.
Affected Files
| File | Contribution |
|---|---|
Phantom.Workspaces.Llm.Core\CopilotSubAgentRouter.cs (162ΓÇô195, 197ΓÇô241, 246ΓÇô270, 272ΓÇô286) |
Two divergent routing paths selected by factory; parent-sink / drop fallbacks; async receiver-registration window |
Phantom.Workspaces.Llm.Core\CopilotSdkStreamAdapter.cs (195ΓÇô231) |
Tag/GetParentToolCallId/TryGetSubAgentId ΓÇö attribution is a conditionally-present per-content tag the SDK may omit |
Phantom.Workspaces.Llm.Core\AgentChatFactory.cs (78/154/213/243ΓÇô246) |
WithSelfAsFactory stamps a non-null factory into every chat (the #1036 trigger) |
Phantom.Workspaces.Llm.Core\AgentChat.cs (260, 1061) |
Feeds factory into the router; foreground-scheduler UI insertion for sub-agents (#913) |
Design / Fix ΓÇö the single unified routing path (shared with #1109)
Resolved by the single unified routing path designed canonically in #1109's Design / Fix. The factory != null branch is deleted; there is one child-sink registry keyed by agentId, one deterministic GetTarget that never silently falls back to the parent for content that belongs to a child, synchronous child-sink registration (buffer-then-flush) that closes the async registration window, and one #913 foreground-scheduler marshaling flow. Because routing no longer reads this.factory, the #1036 WithSelfAsFactory change becomes routing-neutral. Per the owner directive, #1109 additionally enforces RunningAgentChatFactory as a mandatory construction-time dependency (non-nullable, guarded, self-injected via WithSelfAsFactory), so the null factory case that selected the old path becomes structurally impossible.
For this issue's specific symptom, the unified path guarantees a sub-agent delta is always attributed to its owning child sink ΓÇö whether or not the SDK tagged it, and whether or not the receiver has finished constructing ΓÇö so the child's output can never render under the parent.
Implement this as one change with #1109; both issues share the fix.
Expected Tests
Covered by the unified-path test suite in #1109's Expected Tests. The scenarios that specifically guard this issue's symptom:
| Test Name | Class | What It Verifies |
|---|---|---|
SubAgentDelta_ForRegisteredChild_RoutedToChild_NeverRootStream |
CopilotSdkChatClientSubAgentRoutingTests |
A sub-agent's own content reaches the child sink and never the root/parent stream |
SubAgentDelta_BeforeReceiverCreated_BufferedThenFlushedToChild_NotParent |
CopilotSdkChatClientSubAgentFactoryTests |
Child deltas emitted before the receiver is registered are buffered and delivered to the child, not the parent |
UnknownSubAgentId_HeldForLateRegistration_NotMisattributedToParent |
CopilotSdkChatClientSubAgentFactoryTests |
A present-but-unregistered agentId is held/buffered, not dropped or pushed to the parent |
SubAgentRouting_WithFactoryPresentAndAbsent_BehavesIdentically |
CopilotSdkChatClientSubAgentFactoryTests |
Routing is identical whether or not RunningAgentChatFactory is set |
Considered / Background ΓÇö the dual-path design is being removed
Retained only as context (superseded by the unified path):
- Attribution by optional per-content tag.
Tag(222ΓÇô231) stampedParentToolCallIdPropertyNameonly for non-emptyAgentId; untagged child content returnednullfromGetParentToolCallIdand was routed to root. The unified design registers each child sink synchronously so a missing tag can no longer send child content to the parent. - Registry path (Bug: sub-agent output never appears live in the HTML chat view — registry-path sub-agent AgentChat gets no foreground scheduler; WebView auto-flush timer binds to a dead thread-pool dispatcher #913). Added the foreground-scheduler flow so child output renders live; preserved by the unified path.
- Factory path (Wire CopilotSdkChatClient to handle SubAgentStarted using IRunningAgentChatFactory and ISubAgentTable #674) + self-injection (Sub-agents not restored on session reopen: RunningAgentChatFactory never injected into AgentServices #1036). Added live child creation and sub-agent restoration on reopened sessions; the unified path preserves restoration while removing the divergent routing and its async-registration window.
Activity
JoshuaRowePhantom commentedon Jul 22, 2026
Verification result
Status: ✅ Verified
Commits checked:
Outstanding gaps
None — all criteria satisfied.
Notes
Resolved by the unified single-path router landed in #1109 (same commit
44bbe07d): thePushUpdatebranch that used to drop or misroute unknown-agentId events is gone. For a non-emptyagentIdthe router ALWAYS routes into aChildRoutingEntry(creating a buffering entry synchronously on first sight if the lifecycle-start has not yet arrived), so sub-agent output can never leak into the parent transcript - not while the child receiver is being asynchronously created, not when the SDK omits theAgentIdtag mid-content (that content is buffered on the child's entry keyed by whatever synchronously-created entry owns the agent, and content that lacks any agent-id becomes root-only). Regression tests added in commitd127aa38(named per this issue's Expected Tests table):SubAgentDelta_ForRegisteredChild_RoutedToChild_NeverRootStreamasserts BOTH the child receiver observes the delta AND the parent root channel does not;ParentTurnDelta_WhileSubAgentActive_NotRoutedToChildSinkasserts the inverse - genuine parent content still reaches root and is not duplicated into the child. Additional coverage from #1109 (SubAgentEvent_UnknownSubAgentId_BufferedThenFlushedOnLateStart_NeverParent,SubAgentDelta_ForRegisteredChild_RoutedToChild_NeverParent,RouteAsync_BufferedSubAgentDelta_FlushedToChildReceiver_OnLateStart) exercises the exactBeforeReceiverCreated_BufferedThenFlushedToChild_NotParentscenario the issue calls out. Same test runs and stability passes as #1109.Tests
Fix #1110: sub-agent output never leaks into the parent transcript
JoshuaRowePhantom commentedon Jul 22, 2026
Checked in
Commit: fa758e0
Branch fast-forwarded: features → fa758e0