Skip to content

Wire CopilotSdkChatClient to handle SubAgentStarted using IRunningAgentChatFactory and ISubAgentTable #674

Closed

Description

@JoshuaRowePhantom

Summary

Wire up CopilotSdkChatClient to handle the Copilot SDK SubAgentStarted event: inject IRunningAgentChatFactory and ISubAgentTable at construction, then on SubAgentStarted create the sub-agent AgentChat via the factory, acquire the ICopilotSubAgentReceiver from it, register it with the parent via ISubAgentTable, and forward subsequent SDK events through the receiver.

Part of #659.


Prerequisites

  • #670 — IRunningAgentChatFactory (used to call CreateAsync)
  • #671 — AgentChat implements IServiceProvider (used to call agentChat.GetService<ICopilotSubAgentReceiver>())
  • #672 — ISubAgentTable (used to call subAgentTable.Add(agentChat))
  • #673 — ICopilotSubAgentReceiver + github-copilot-subagent provider (the receiver that GetService must return)

Design

CopilotSdkChatClient receives IRunningAgentChatFactory and ISubAgentTable at construction, injected by the parent AgentChat in InitializeAsync (same pattern as the SteeringMessageForwarded subscription).

SubAgentStarted handler flow

  1. Construct the sub-agent AgentDefinition internally: {"kind":"prompt","model":{"provider":"github-copilot-subagent"}}
  2. Generate a new AgentSessionId
  3. Call factory.CreateAsync(definition, sessionId) → RunningAgentChatLease → AgentChat
  4. Query the AgentChat for the receiver:
    var receiver = agentChat.GetService<ICopilotSubAgentReceiver>()
        ?? throw new InvalidOperationException("Sub-agent AgentChat does not expose ICopilotSubAgentReceiver");
  5. Call subAgentTable.Add(lease.AgentChat) → SubAgent (parent records persistence link, adds to SubAgents collection)
  6. Forward subsequent Copilot SDK sub-agent events: receiver.Push(...), receiver.Complete(), receiver.Fail(...)

CopilotSdkChatClient does not raise any event for sub-agent creation and does not create a channel or receiver itself.

Unknown sub-agent ID

If a SubAgentEvent, SubAgentCompleted, or SubAgentFailed arrives for a sub-agent ID with no registered receiver (i.e. SubAgentStarted was never seen for that ID), log the event at Warning level and ignore it. Do not throw.


Files affected

File Change
Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs Accept IRunningAgentChatFactory + ISubAgentTable; handle SubAgentStarted, SubAgentEvent, SubAgentCompleted, SubAgentFailed
Phantom.Workspaces.Llm.Core/AgentChat.cs Inject IRunningAgentChatFactory and ISubAgentTable into CopilotSdkChatClient

Expected tests

Test name What it verifies
SubAgentStarted_CallsFactoryCreateAsync_WithGithubCopilotSubagentDefinition The AgentDefinition passed to CreateAsync has provider github-copilot-subagent
SubAgentStarted_CallsSubAgentTableAdd_WithCreatedAgentChat After SubAgentStarted, ISubAgentTable.Add is called with the AgentChat from the factory lease
SubAgentStarted_AcquiresReceiverFromSubAgentChat agentChat.GetService<ICopilotSubAgentReceiver>() is called on the newly created sub-agent chat
SubAgentEvent_ForwardedToReceiver_Push A SubAgentEvent SDK message is forwarded to the correct receiver via Push
SubAgentCompleted_ForwardedToReceiver_Complete A SubAgentCompleted SDK message calls receiver.Complete()
SubAgentFailed_ForwardedToReceiver_Fail A SubAgentFailed SDK message calls receiver.Fail(exception) with the correct exception
SubAgentEvent_UnknownSubAgentId_LoggedAndIgnored An event for an unknown sub-agent ID does not throw; a Warning-level log entry is emitted
SubAgentCompleted_UnknownSubAgentId_LoggedAndIgnored Same for SubAgentCompleted with an unknown ID
SubAgentStarted_ReceiverNotExposedBySubAgentChat_ThrowsInvalidOperationException If GetService<ICopilotSubAgentReceiver>() returns null, an InvalidOperationException is thrown
SubAgentStarted_MultipleSubAgents_EachRoutedToCorrectReceiver Two concurrent sub-agents each receive their own events independently via separate receivers

Activity

  1. JoshuaRowePhantom commented on Jul 3, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Tests

    All tests target CopilotSdkTurnEventDispatcher (via Phantom.Workspaces.Llm.Core.Tests). Each test uses fake implementations of IRunningAgentChatFactory, ISubAgentTable, and (implicitly) real AgentChat instances with ClientOverride = new CopilotSubAgentChatClient().

    Test name What it verifies
    SubAgentStarted_CallsFactoryCreateAsync_WithGithubCopilotSubagent The AgentDefinition passed to actory.CreateAsync has provider github-copilot-subagent
    SubAgentStarted_CallsSubAgentTableAdd_WithCreatedAgentChat After SubagentStartedEvent, ISubAgentTable.Add is called with the AgentChat from the lease
    SubAgentStarted_AcquiresReceiverFromSubAgentChat �gentChat.GetService() is called; the receiver is stored for routing
    SubAgentEvent_ForwardedToReceiver_Push A ToolExecutionStartEvent (or delta) with a known sub-agent ID calls
    eceiver.Push(update)
    SubAgentCompleted_ForwardedToReceiver_Complete A SubagentCompletedEvent for a known ID calls
    eceiver.Complete()
    SubAgentFailed_ForwardedToReceiver_Fail A SubagentFailedEvent for a known ID calls
    eceiver.Fail(exception)
    SubAgentEvent_UnknownSubAgentId_LoggedAndIgnored An event with an unregistered agent ID does not throw; a Warning is logged
    SubAgentCompleted_UnknownSubAgentId_LoggedAndIgnored Same for SubagentCompletedEvent with an unknown ID
    SubAgentStarted_ReceiverNotExposedBySubAgentChat_Throws If GetService() returns
    ull, InvalidOperationException is thrown
    SubAgentStarted_MultipleSubAgents_EachRoutedToCorrectReceiver Two concurrent sub-agents each receive only their own events
  2. JoshuaRowePhantom commented on Jul 3, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Design

    Approach: Wire CopilotSdkChatClient to handle SubAgentStarted using IRunningAgentChatFactory and ISubAgentTable as specified.

    Design decisions

    1. Two-path dispatcher: The dispatcher keeps both the existing ISubAgentChatRegistry path and adds a new factory/table path. When IRunningAgentChatFactory + ISubAgentTable are both provided, the factory path is used for SubagentStartedEvent; otherwise the registry path fires.

    2. Receiver map: A Dictionary<string, ICopilotSubAgentReceiver> keyed by sub-agent ID stores the receiver extracted from each sub-agent's AgentChat.GetService<ICopilotSubAgentReceiver>(). All subsequent events with that agent ID are forwarded to the receiver via Push/Complete/Fail.

    3. Lease lifetime: The RunningAgentChatLease is held in a parallel dictionary and disposed when the sub-agent completes or fails.

    4. Injection pattern: CopilotSdkChatClient gains an internal SetSubAgentDependencies(IRunningAgentChatFactory?, ISubAgentTable?) method called from AgentChat.InitializeAsync in the same block that subscribes to SteeringMessageForwarded.

    5. Unknown agent ID: Logged at Warning level and ignored -- no throw, no fallback to root stream.

    6. AgentDefinition: Sub-agents created with {"kind":"prompt","model":{"provider":"github-copilot-subagent"}} so AgentFactory creates a CopilotSubAgentChatClient.

    Files affected

    • CopilotSdkChatClient.cs -- Add SetSubAgentDependencies; pass factory/table/logger to dispatcher
    • CopilotSdkTurnEventDispatcher.cs -- New factory/table/logger params; factory-path handling; receiver routing; warning logging
    • AgentChat.cs -- Call SetSubAgentDependencies in InitializeAsync
    • CopilotSdkChatClientSubAgentFactoryTests.cs -- New test file for factory-path tests
  3. JoshuaRowePhantom commented on Jul 3, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Resolution

    Approach: Added factory-path sub-agent wiring to CopilotSdkTurnEventDispatcher, injected via CopilotSdkChatClient.SetSubAgentDependencies called from AgentChat.InitializeAsync.

    Changes:

    • Phantom.Workspaces.Llm.Core/CopilotSdkTurnEventDispatcher.cs -- New optional constructor params IRunningAgentChatFactory?, ISubAgentTable?, ILogger?; factory-path SubagentStartedEvent handler creates an AgentChat via factory.CreateAsync (using the github-copilot-subagent definition), extracts ICopilotSubAgentReceiver via agentChat.GetService, calls subAgentTable.Add(agentChat), stores receiver keyed by agent ID. All subsequent events for that ID routed through the receiver's Push/Complete/Fail. Unknown IDs logged at Warning and ignored. Leases cleaned up via DisposeRemainingLeasesAsync when the turn subscription tears down.
    • Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs -- New internal SetSubAgentDependencies(IRunningAgentChatFactory?, ISubAgentTable?) method stores dependencies; BeginTurnAsync passes them plus a logger to the dispatcher; subscription teardown fires-and-forgets DisposeRemainingLeasesAsync.
    • Phantom.Workspaces.Llm.Core/AgentChat.cs -- InitializeAsync calls copilotChatClient.SetSubAgentDependencies(agentServices.RunningAgentChatFactory as IRunningAgentChatFactory, this) in the same block that subscribes to SteeringMessageForwarded.
    • Phantom.Workspaces.Llm.Core.Tests/CopilotSdkChatClientSubAgentFactoryTests.cs -- 11 new tests covering all specified cases (factory wiring, table add, receiver acquisition, event forwarding, unknown-ID logging, no-receiver error, multi-agent routing).

    Deviations from design: None. Implementation follows the spec exactly.

    Commit: 5da0f0e

  4. JoshuaRowePhantom commented on Jul 6, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Verification passed

    Checked: Commit 5da0f0e on the eatures branch. Files inspected:

    • Phantom.Workspaces.Llm.Core/CopilotSdkTurnEventDispatcher.cs — factory-path SubagentStartedEvent handler, receiver routing dictionary, unknown-ID warning logging, DisposeRemainingLeasesAsync
    • Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs — SetSubAgentDependencies method and pass-through to dispatcher
    • Phantom.Workspaces.Llm.Core/AgentChat.cs — InitializeAsync wiring of SetSubAgentDependencies
    • Phantom.Workspaces.Llm.Core.Tests/CopilotSdkChatClientSubAgentFactoryTests.cs — 11 tests

    Result: Implementation found, all verification criteria satisfied.

    All 11 specified test cases are present and match the issue spec: factory wiring, table add, receiver acquisition, event forwarding (Push/Complete/Fail), unknown-ID warning logging, receiver-not-exposed exception, and multi-sub-agent routing. No disabled tests, unresolved TODOs, or timing-dependent tests found.

  5. added a commit that references this issue on Jul 7, 2026
    5da0f0e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestverifiedIssue has been verified

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions