Repository navigation
Route sub-agent events in CopilotSdkChatClient and buffer tool-call prompts #622
Description
Parent: Part of #34
Scope
Make CopilotSdkChatClient implement ISubAgentChat for the root stream and wire an ISubAgentChatRegistry dependency. Add a GetTarget(agentId?) helper that dispatches delta/tool events to the correct sink (root or child). Handle SubagentStartedEvent, SubagentCompletedEvent, and SubagentFailedEvent via session.On(...). Buffer arriving ToolExecutionStartEvent items keyed by tool-call ID so that when SubagentStartedEvent fires, the tool-call arguments can be injected as the sub-agent's first history message (handling the race where either event may arrive first).
Relevant design extract (from #34)
SDK event surface
The Copilot SDK exposes five first-class sub-agent events via session.On(...):
| Event | Meaning |
|---|---|
SubagentStartedEvent |
A sub-agent was launched; carries AgentId, ParentToolCallId, AgentName, AgentDisplayName, AgentDescription |
SubagentCompletedEvent |
Sub-agent finished; carries DurationMs, TotalTokens, TotalToolCalls |
SubagentFailedEvent |
Sub-agent failed; carries Error, DurationMs |
SubagentSelectedEvent |
Session-level selection signal (no AgentId) |
SubagentDeselectedEvent |
Session-level deselection signal (no AgentId) |
Every streaming event also carries two routing signals:
| Signal | Meaning |
|---|---|
SessionEvent.AgentId |
Non-null for any event originating inside a sub-agent. Stable identity for its lifetime. |
*.Data.ParentToolCallId |
ID of the tool call in the parent session that spawned this sub-agent. Present on AssistantMessageDeltaEvent, AssistantMessageEvent, AssistantUsageEvent, ToolExecutionStartEvent, ToolExecutionCompleteEvent. |
§3 — CopilotSdkChatClient changes
CopilotSdkChatClient implements ISubAgentChat for the root stream. A GetTarget helper dispatches events:
private ISubAgentChat GetTarget(string? agentId) =>
agentId == null ? this : this.subAgentRegistry?.TryGet(agentId) ?? this;The session.On(...) handler builds an AgentDefinition from the SDK event metadata and calls GetOrCreateAsync:
case SubagentStartedEvent started:
var subDef = AgentDefinition.ForHostedSubAgent(
started.Data.AgentDisplayName,
started.Data.AgentDescription);
await (this.subAgentRegistry?.GetOrCreateAsync(
started.AgentId ?? started.Data.ToolCallId,
subDef,
started.Data.ToolCallId) ?? Task.CompletedTask);
break;
case SubagentCompletedEvent completed:
this.subAgentRegistry?.TryGet(completed.AgentId ?? completed.Data.ToolCallId)?.Complete();
break;
case SubagentFailedEvent failed:
this.subAgentRegistry?.TryGet(failed.AgentId ?? failed.Data.ToolCallId)
?.Fail(new AgentSubagentFailedException(failed.Error));
break;
case AssistantMessageDeltaEvent delta when !string.IsNullOrEmpty(delta.Data.DeltaContent):
GetTarget(delta.AgentId).Push(new ChatResponseUpdate(ChatRole.Assistant, delta.Data.DeltaContent));
break;
case ToolExecutionStartEvent toolStart:
GetTarget(toolStart.AgentId).Push(MapToolStart(toolStart));
break;
case ToolExecutionCompleteEvent toolComplete:
GetTarget(toolComplete.AgentId).Push(MapToolComplete(toolComplete));
break;SubagentSelectedEvent and SubagentDeselectedEvent are recorded as diagnostic entries on the root history only.
§12 — Tool call prompt as first history message
When a sub-agent is spawned, the parent's ToolExecutionStartEvent for the matching ParentToolCallId contains the tool call arguments — this is effectively the "prompt" given to the sub-agent. It must be recorded as the first message in the sub-agent's chat history.
Design:
CopilotSdkChatClientbuffersToolExecutionStartEvententries (keyed by tool call ID) as they arrive on the root stream.- When
SubagentStartedEventfires, look up the bufferedToolExecutionStartEventforstarted.Data.ParentToolCallId. - If found, push the tool call arguments as the first message to the child
ISubAgentChatbefore any other content is pushed. - If the
ToolExecutionStartEventhas not yet arrived whenSubagentStartedEventfires (ordering edge case), buffer the sub-agent start and flush when the tool start event arrives.
§8 — Setting ParentToolCallId on history items
When ToolExecutionCompleteEvent arrives for a tool call ID that was recorded as spawning a sub-agent (i.e., it matches a SubagentStartedEvent.Data.ParentToolCallId), the resulting AgentChatHistoryItem must be constructed with ParentToolCallId set. This enables the jump link to the sub-agent's view (rendered in #629).
Artifacts
| File | Change |
|---|---|
| Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs | Implement ISubAgentChat; inject ISubAgentChatRegistry?; add GetTarget; wire SDK lifecycle events; buffer ToolExecutionStartEvent for prompt injection; set ParentToolCallId on tool-complete history items |
| Phantom.Workspaces.Llm.Core.Tests/CopilotSdkChatClientSubAgentRoutingTests.cs | New — unit tests using a fake ISubAgentChatRegistry (see below) |
Expected tests
AssistantMessageDeltaEvent_NullAgentId_WrittenToRootStream— delta withAgentId == nullgoes to rootAssistantMessageDeltaEvent_WithAgentId_WrittenToChildStream— delta withAgentIdgoes to child sinkSubagentStartedEvent_CallsGetOrCreateAsync_WithAgentDefinition— registryGetOrCreateAsyncinvoked with correct metadataSubagentCompletedEvent_CallsComplete_OnChildSink— child sinkComplete()calledSubagentFailedEvent_CallsFail_OnChildSink— child sinkFail(AgentSubagentFailedException)calledToolStart_ArrivesBeforeSubagentStarted_PromptInjectedAsFirstMessage— tool-start event buffered; injected when sub-agent startsSubagentStarted_ArrivesBeforeToolStart_PromptInjectedWhenToolStartArrives— prompt injection still works when ordering is reversedToolExecutionStartEvent_WithAgentId_RoutedToChildSink— tool-start on sub-agent routed correctlyToolExecutionCompleteEvent_WithAgentId_RoutedToChildSink— tool-complete on sub-agent routed correctlySubAgent_ToolCallPrompt_IsFirstHistoryMessage— sub-agent history first message content matches tool call arguments fromToolExecutionStartEventSubAgent_ToolCallPrompt_SubagentStartedBeforeToolStart_StillRecorded— prompt still recorded as first message whenSubagentStartedEventarrives beforeToolExecutionStartEvent
Verification steps
.\scripts\run-tests.ps1 -TestNames CopilotSdkChatClientSubAgentRoutingTests— all tests green.dotnet build Phantom.Workspaces.Llm.Core— zero errors.
Dependencies
Requires #619 (core interfaces), #621 (AgentChatHistoryItem.ParentToolCallId).
Activity
JoshuaRowePhantom commentedon Jul 2, 2026
Depends on: #619, #620
JoshuaRowePhantom commentedon Jul 2, 2026
Design
Root cause / approach: CopilotSdkChatClient currently writes all streaming events directly to a per-turn channel, ignoring the AgentId routing signal. Sub-agent events are not handled at all.
Design decisions:
Files changing:
JoshuaRowePhantom commentedon Jul 2, 2026
Tests
All in Phantom.Workspaces.Llm.Core.Tests/CopilotSdkChatClientSubAgentRoutingTests.cs:
JoshuaRowePhantom commentedon Jul 2, 2026
Implementation complete
Implemented in commit \
Fix #619: add ISubAgentChat, ISubAgentChatRegistry, IHostedAgentChatC…
Fix #622: route sub-agent events in CopilotSdkChatClient and buffer t…
JoshuaRowePhantom commentedon Jul 2, 2026
Verification failed
Checked: Commit