diff --git a/docs/design/subagent-tool-dispatch.md b/docs/design/subagent-tool-dispatch.md new file mode 100644 index 000000000..72ee55013 --- /dev/null +++ b/docs/design/subagent-tool-dispatch.md @@ -0,0 +1,368 @@ +# Subagent Tool Dispatch: Cross-SDK Design + +> Protocol-level specification for resolving child session IDs created by subagents +> and dispatching tool calls, permission requests, hooks, and user-input requests +> back to the parent session that owns the handlers. + +## Problem + +When a user configures **custom agents** (subagents) on a session, the Copilot CLI +creates a *child session* for each agent invocation. The child session has its own +session ID that is **not** in the SDK's session registry — only parent sessions are +registered by `createSession()`. + +All four request types (`tool.call`, `permission.request`, `hooks.invoke`, +`userInput.request`) may arrive with a child session ID. Without resolution logic +the SDK returns "unknown session", breaking the entire subagent feature. + +## Request Flow + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Parent │ createSession │ SDK │ JSON-RPC init │ Copilot │ +│ App │ ───────────────▶ │ Client │ ────────────────▶ │ CLI │ +│ │ (tools, agents) │ │ │ │ +└──────────┘ └────┬─────┘ └────┬─────┘ + │ │ + 1. session.event ◀───────┼──────────────────────────────┤ + type: subagent.started │ │ + data.remoteSessionId │ │ + data.toolCallId │ │ + data.agentName │ │ + │ │ + 2. SDK maps │ │ + child → parent │ │ + child → agentName │ │ + │ │ + 3. tool.call ◀─────────┼──────────────────────────────┤ + sessionId = CHILD_ID │ │ + toolName, arguments │ │ + │ │ + 4. resolveSession(CHILD) │ │ + → parent session │ │ + + allowlist check │ │ + │ │ + 5. invoke tool handler │ │ + return result ────────┼─────────────────────────────▶│ + │ │ + 6. session.event ◀───────┼──────────────────────────────┤ + type: subagent.completed │ + data.toolCallId │ │ + ▼ ▼ +``` + +Steps 3–5 repeat for `permission.request`, `hooks.invoke`, and `userInput.request`. + +## Protocol Contract + +### Session Events (parent session's event stream) + +All subagent events arrive as `session.event` notifications keyed by the +**parent** session's `sessionEventRequest.sessionId`. + +| Event type | Key `data` fields | Purpose | +|-----------------------|------------------------------------------------------------------------|--------------------------------| +| `subagent.started` | `remoteSessionId` (child ID), `toolCallId`, `agentName`, `agentDisplayName` | Register child → parent mapping | +| `subagent.completed` | `toolCallId`, `agentName` | Cleanup instance tracking | +| `subagent.failed` | `toolCallId`, `agentName`, `error` | Cleanup instance tracking | + +> **Ordering guarantee:** The CLI emits `subagent.started` before the first +> request that uses the child session ID. + +### Request Types Requiring Resolution + +| JSON-RPC method | Params include `sessionId` | Child sessions possible? | +|-----------------------|----------------------------|--------------------------| +| `tool.call` | ✅ | ✅ | +| `permission.request` | ✅ | ✅ | +| `hooks.invoke` | ✅ | ✅ | +| `userInput.request` | ✅ | ✅ | + +## Data Model + +All SDKs must maintain three maps on the client instance. Names below are +language-agnostic; adapt casing to each language's conventions. + +``` +childToParent: Map +childToAgent: Map +subagentInstances: Map> +``` + +**SubagentInstance** fields: + +| Field | Type | Description | +|------------------|----------|-------------------------------------------| +| `agentName` | string | Custom agent name from `subagent.started` | +| `toolCallId` | string | Unique tool call ID for this launch | +| `childSessionId` | string | Child session ID (from `remoteSessionId`) | +| `startedAt` | datetime | Timestamp of the `subagent.started` event | + +All maps must be protected by appropriate synchronization primitives +(see [Concurrency Requirements](#concurrency-requirements)). + +## Required Behavior + +### Session Resolution + +Every request handler must resolve the incoming `sessionId` through a single +shared function: + +``` +resolveSession(sessionId) → (session, isChild, error) +``` + +Algorithm: + +1. **Direct lookup**: if `sessions[sessionId]` exists → return `(session, false, nil)` +2. **Child lookup**: if `childToParent[sessionId]` exists: + - let `parentId = childToParent[sessionId]` + - if `sessions[parentId]` exists → return `(parentSession, true, nil)` + - else → error: `"parent session {parentId} for child {sessionId} not found"` +3. **Unknown** → error: `"unknown session {sessionId}"` + +### Handler Dispatch + +Each handler follows the same pattern: + +``` +(session, isChild, err) = resolveSession(params.sessionId) +if err → return error response + +// For tool.call ONLY: enforce allowlist +if isChild AND handler == tool.call: + if not isToolAllowedForChild(params.sessionId, params.toolName): + return error: "Tool '{toolName}' is not supported by this client instance." + +// Dispatch to the resolved session's handler +return session.handle(params) +``` + +## Allowlist Enforcement + +The `CustomAgentConfig.tools` field controls which parent tools a subagent can +invoke. + +| `tools` value | Meaning | +|------------------------|------------------------------------------| +| `null` / `nil` / `None` / not set | All parent tools accessible | +| `[]` (empty list) | No tools accessible | +| `["a", "b"]` | Only tools `a` and `b` accessible | + +**Rules:** + +- Allowlist check applies to both `tool.call` RPC requests (Protocol v2) + and `external_tool.requested` broadcast events (Protocol v3). It does + **not** apply to `permission.request`, `hooks.invoke`, or + `userInput.request`. +- A denied tool returns `"Tool '{name}' is not supported by this client instance."` + — never `"unknown session"`. +- The check algorithm: + 1. Look up `agentName = childToAgent[childSessionId]` + 2. Look up `CustomAgentConfig` for `agentName` on the parent session + 3. If `tools` is null/unset → **allow** + 4. If `toolName` is in `tools` list → **allow** + 5. Otherwise → **deny** + +## Tool Advertisement + +### Problem + +Child sessions created by the CLI for subagents do not automatically inherit +parent custom tool definitions. The child session's LLM only sees built-in +tools — it has no knowledge of any custom tools the parent session registered +unless their definitions are explicitly forwarded. + +### SDK Mechanism + +The SDK auto-populates `toolDefinitions` on each `CustomAgentConfig` in the +`session.create` and `session.resume` requests. This field contains the full +tool definitions (name, description, parameters) for every tool listed in the +agent's `Tools` allowlist. + +When `Tools` is `nil` / `null` / unset (meaning "all tools"), the SDK does +**not** populate `toolDefinitions` — enumerating all tools is unnecessary +because the CLI already has the full tool list from the session-level `Tools` +array. + +### Wire Format + +```json +{ + "customAgents": [{ + "name": "reviewer", + "tools": ["save_result"], + "toolDefinitions": [ + { + "name": "save_result", + "description": "Saves a result string", + "parameters": { + "type": "object", + "properties": { + "content": { "type": "string", "description": "The result to save" } + }, + "required": ["content"] + } + } + ] + }] +} +``` + +### CLI Dependency + +The CLI must read and propagate `toolDefinitions` to child sessions for custom +tools to be visible to subagent LLMs. If the CLI does not support this field, +custom tools will **not** be available to subagents — the child LLM will not +know they exist and will never attempt to call them. SDK-side allowlist +enforcement alone is not sufficient; tool advertisement is the complementary +mechanism that makes custom tools discoverable. + +## Protocol v3 Allowlist Enforcement + +In Protocol v3, tool calls from child sessions arrive as +`external_tool.requested` broadcast events on the **parent** session's event +stream, rather than as direct JSON-RPC requests to the client. + +### Client-Level Interception + +The client intercepts these events in `handleSessionEvent()` and enforces the +tool allowlist **before** dispatching to the session's tool handler: + +1. Extract the child session ID and tool name from the broadcast event. +2. Resolve the child session to its parent using `childToParent`. +3. Look up the agent's `Tools` allowlist via `childToAgent` → agent config. +4. If the tool is **denied**, respond with a failure via + `session.tools.handlePendingToolCall` RPC — the tool handler is never + invoked. +5. If the tool is **allowed**, forward the event to the resolved parent + session for normal tool dispatch. + +### Why Client-Level? + +This enforcement is done at the **client** level (not session level) because +the session object does not have access to the child-to-parent mapping or the +per-agent allowlist configuration. Only the client maintains the +`childToParent`, `childToAgent`, and agent config data structures needed to +make the allow/deny decision. + +## Cleanup Contract + +| Trigger | `childToParent` | `childToAgent` | `subagentInstances` | +|-----------------------------------|-----------------|----------------|----------------------| +| Client `stop()` / shutdown | Clear all | Clear all | Clear all | +| Delete single session (parent) | Remove children of that parent | Remove children of that parent | Remove parent entry | +| Destroy session (parent) | Remove children of that parent | Remove children of that parent | Remove parent entry, fire cleanup callback | +| `subagent.completed` / `failed` | **Preserve** | **Preserve** | Remove instance only | + +> **Why preserve on subagent end?** Requests may still be in-flight after the +> `completed`/`failed` event. Keeping `childToParent` and `childToAgent` ensures +> those late-arriving requests resolve correctly. + +## Error Types + +| Error message | Condition | +|------------------------------------------------------------------|--------------------------------------------------------------| +| `unknown session {id}` | `sessionId` not found as direct session or child mapping | +| `parent session {parentId} for child {childId} not found` | Child mapping exists but parent session was deleted/destroyed | +| `Tool '{name}' is not supported by this client instance.` | Tool not in agent's allowlist, or tool not registered | + +## Concurrency Requirements + +- All map access (`childToParent`, `childToAgent`, `subagentInstances`) must be + synchronized. +- The lock must **not** be held during handler execution (tool handler calls, + permission callbacks, hook invocations). This prevents deadlocks when a handler + triggers further session operations. +- Lock is acquired only for map reads/writes, then released before callback + dispatch. + +``` +lock() +(session, isChild, err) = read maps +unlock() + +// handler runs WITHOUT lock +result = session.handle(params) +``` + +## Language-Specific Notes + +### Node SDK (`nodejs/src/client.ts`) + +**New client properties:** + +```typescript +private childToParent: Map = new Map(); +private childToAgent: Map = new Map(); +private subagentInstances: Map> = new Map(); +``` + +**Integration points:** + +1. **Event interception**: In the session event handler, intercept + `subagent.started`, `subagent.completed`, and `subagent.failed` events to + populate/clean up the maps. +2. **`handleToolCallRequest`**: Replace direct `this.sessions.get(sessionId)` + with `this.resolveSession(sessionId)`. Add allowlist check when `isChild`. +3. **`handlePermissionRequest`**, **`handleUserInputRequest`**, + **`handleHooksInvoke`**: Replace direct session lookup with + `this.resolveSession(sessionId)`. + +**Concurrency**: Node.js is single-threaded (event loop), so no mutex is needed. +Standard `Map` operations are safe. + +### Python SDK (`python/copilot/client.py`) + +**New client properties:** + +```python +self._child_to_parent: dict[str, str] = {} +self._child_to_agent: dict[str, str] = {} +self._subagent_instances: dict[str, dict[str, SubagentInstance]] = {} +``` + +**Integration points:** + +1. **Event interception**: In the session event callback, intercept + `subagent.started` / `completed` / `failed` to manage the maps. +2. **`_handle_tool_call_request`**: Replace direct `self._sessions.get()` + with `self._resolve_session()`. Add allowlist check when `is_child`. +3. **`_handle_permission_request`**, **`_handle_user_input_request`**, + **`_handle_hooks_invoke`**: Replace direct session lookup with + `self._resolve_session()`. + +**Concurrency**: Python asyncio is single-threaded within an event loop. If the +client is used from multiple threads, protect the maps with `self._sessions_lock` +(already exists on the Python client). Under pure asyncio, no extra lock is +needed. + +### Go SDK (`go/client.go`) + +The Go SDK already implements this feature. The Go implementation is the +**reference implementation** for this design. Key structures: + +- `childToParent`, `childToAgent`, `subagentInstances` maps on `Client` +- `resolveSession()` method with direct → child fallback +- `isToolAllowedForChild()` for allowlist enforcement +- `sync.RWMutex` (`mu`) protects all map access + +### .NET SDK (`dotnet/src/`) + +Follow the same pattern. Use `ConcurrentDictionary` for +`childToParent` and `childToAgent`, or protect with a `ReaderWriterLockSlim`. +The `SubagentInstance` can be a record or class. + +## Checklist for SDK Implementers + +- [ ] Add `childToParent`, `childToAgent`, `subagentInstances` maps to client +- [ ] Intercept `subagent.started` event → populate maps +- [ ] Intercept `subagent.completed` / `subagent.failed` → cleanup instances +- [ ] Implement `resolveSession()` with direct → child fallback +- [ ] Update `tool.call` handler to use `resolveSession()` + allowlist check +- [ ] Update `permission.request` handler to use `resolveSession()` +- [ ] Update `hooks.invoke` handler to use `resolveSession()` +- [ ] Update `userInput.request` handler to use `resolveSession()` +- [ ] Cleanup maps on client stop, session delete, and session destroy +- [ ] Verify concurrency safety for the language's execution model +- [ ] Add tests: child tool dispatch, allowlist deny, unknown session error diff --git a/go/README.md b/go/README.md index 46356eabf..fb37ca4c8 100644 --- a/go/README.md +++ b/go/README.md @@ -368,6 +368,62 @@ safeLookup := copilot.DefineTool("safe_lookup", "A read-only lookup that needs n safeLookup.SkipPermission = true ``` +### Custom Tools with Subagents + +When a session is configured with both custom tools and custom agents (subagents), the +subagents can invoke the parent session's custom tools. The SDK automatically routes +tool calls from child sessions back to the parent session's tool handlers. + +#### Tool Access Control + +The `Tools` field on `CustomAgentConfig` controls which custom tools each subagent can access: + +| `Tools` value | Behavior | +|---------------|----------| +| `nil` (default) | Subagent can access **all** custom tools registered on the parent session | +| `[]string{}` (empty) | Subagent cannot access **any** custom tools | +| `[]string{"tool_a", "tool_b"}` | Subagent can only access the listed tools | + +#### Example + +```go +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Tools: []copilot.Tool{ + copilot.DefineTool("save_output", "Saves output to storage", + func(params SaveParams, inv copilot.ToolInvocation) (string, error) { + // Handle tool call — works for both direct and subagent invocations + return saveToStorage(params.Content) + }), + copilot.DefineTool("get_data", "Retrieves data from storage", + func(params GetParams, inv copilot.ToolInvocation) (string, error) { + return getData(params.Key) + }), + }, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "researcher", + Description: "Researches topics and saves findings", + Tools: []string{"save_output"}, // Can only use save_output, not get_data + Prompt: "You are a research assistant. Save your findings using save_output.", + }, + { + Name: "analyst", + Description: "Analyzes data from storage", + Tools: nil, // Can access ALL custom tools + Prompt: "You are a data analyst.", + }, + }, +}) +``` + +When `researcher` is invoked as a subagent, it can call `save_output` but not `get_data`. +When `analyst` is invoked, it can call both tools. If a subagent attempts to use a tool +not in its allowlist, the SDK returns a `"Tool '{name}' is not supported by this client instance."` response to the LLM. + +> **Tool advertisement:** When custom agents reference tools via the `Tools` allowlist, the SDK automatically includes full tool definitions (`ToolDefinitions`) in the agent configuration sent to the CLI. This enables the CLI to propagate custom tool metadata to child sessions. +> +> **Protocol v3 enforcement:** Protocol v3 broadcast tool events from child sessions are subject to the same allowlist enforcement as Protocol v2 RPC tool calls. + ## Streaming Enable streaming to receive assistant response chunks as they're generated: diff --git a/go/client.go b/go/client.go index 6f88c768a..2e552b767 100644 --- a/go/client.go +++ b/go/client.go @@ -53,6 +53,14 @@ import ( const noResultPermissionV2Error = "permission handlers cannot return 'no-result' when connected to a protocol v2 server" +// subagentInstance represents a single active subagent launch. +type subagentInstance struct { + agentName string + toolCallID string + childSessionID string // empty until child session ID is known + startedAt time.Time +} + // Client manages the connection to the Copilot CLI server and provides session management. // // The Client can either spawn a CLI server process or connect to an existing server. @@ -81,6 +89,22 @@ type Client struct { state ConnectionState sessions map[string]*Session sessionsMux sync.Mutex + + // childToParent maps childSessionID → parentSessionID. + // Populated exclusively from authoritative protocol signals. + // Protected by sessionsMux. + childToParent map[string]string + + // childToAgent maps childSessionID → agentName. + // Used for allowlist enforcement. Populated alongside childToParent. + // Protected by sessionsMux. + childToAgent map[string]string + + // subagentInstances tracks active subagent launches per parent session. + // Key: parentSessionID → map of toolCallID → subagentInstance. + // Protected by sessionsMux. + subagentInstances map[string]map[string]*subagentInstance + isExternalServer bool conn net.Conn // stores net.Conn for external TCP connections useStdio bool // resolved value from options @@ -129,8 +153,11 @@ func NewClient(options *ClientOptions) *Client { client := &Client{ options: opts, state: StateDisconnected, - sessions: make(map[string]*Session), - actualHost: "localhost", + sessions: make(map[string]*Session), + childToParent: make(map[string]string), + childToAgent: make(map[string]string), + subagentInstances: make(map[string]map[string]*subagentInstance), + actualHost: "localhost", isExternalServer: false, useStdio: true, autoStart: true, // default @@ -346,6 +373,9 @@ func (c *Client) Stop() error { c.sessionsMux.Lock() c.sessions = make(map[string]*Session) + c.childToParent = make(map[string]string) + c.childToAgent = make(map[string]string) + c.subagentInstances = make(map[string]map[string]*subagentInstance) c.sessionsMux.Unlock() c.startStopMux.Lock() @@ -527,6 +557,34 @@ func extractTransformCallbacks(config *SystemMessageConfig) (*SystemMessageConfi return wireConfig, callbacks } +// enrichAgentToolDefinitions populates ToolDefinitions on each agent config +// by matching agent tool names against the session's tool definitions. +// This gives the CLI explicit tool metadata for child session setup. +func enrichAgentToolDefinitions(agents []CustomAgentConfig, sessionTools []Tool) { + toolsByName := make(map[string]Tool, len(sessionTools)) + for _, t := range sessionTools { + toolsByName[t.Name] = t + } + for i := range agents { + if agents[i].Tools == nil { + continue // nil = all tools; don't enumerate + } + defs := make([]Tool, 0, len(agents[i].Tools)) + for _, name := range agents[i].Tools { + if t, ok := toolsByName[name]; ok { + defs = append(defs, Tool{ + Name: t.Name, + Description: t.Description, + Parameters: t.Parameters, + }) + } + } + if len(defs) > 0 { + agents[i].ToolDefinitions = defs + } + } +} + func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Session, error) { if config == nil || config.OnPermissionRequest == nil { return nil, fmt.Errorf("an OnPermissionRequest handler is required when creating a session. For example, to allow all permissions, use &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}") @@ -550,7 +608,12 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers req.EnvValueMode = "direct" - req.CustomAgents = config.CustomAgents + // Copy agents slice so enrichment doesn't mutate the caller's config. + if len(config.CustomAgents) > 0 { + req.CustomAgents = make([]CustomAgentConfig, len(config.CustomAgents)) + copy(req.CustomAgents, config.CustomAgents) + enrichAgentToolDefinitions(req.CustomAgents, config.Tools) + } req.Agent = config.Agent req.SkillDirectories = config.SkillDirectories req.DisabledSkills = config.DisabledSkills @@ -597,6 +660,12 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses // events emitted by the CLI (e.g. session.start) are not dropped. session := newSession(sessionID, c.client, "") + session.customAgents = config.CustomAgents + session.onDestroy = func() { + c.sessionsMux.Lock() + c.removeChildMappingsForParentLocked(session.SessionID) + c.sessionsMux.Unlock() + } session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) if config.OnUserInputRequest != nil { @@ -710,7 +779,12 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, } req.MCPServers = config.MCPServers req.EnvValueMode = "direct" - req.CustomAgents = config.CustomAgents + // Copy agents slice so enrichment doesn't mutate the caller's config. + if len(config.CustomAgents) > 0 { + req.CustomAgents = make([]CustomAgentConfig, len(config.CustomAgents)) + copy(req.CustomAgents, config.CustomAgents) + enrichAgentToolDefinitions(req.CustomAgents, config.Tools) + } req.Agent = config.Agent req.SkillDirectories = config.SkillDirectories req.DisabledSkills = config.DisabledSkills @@ -736,6 +810,12 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, // events emitted by the CLI (e.g. session.start) are not dropped. session := newSession(sessionID, c.client, "") + session.customAgents = config.CustomAgents + session.onDestroy = func() { + c.sessionsMux.Lock() + c.removeChildMappingsForParentLocked(session.SessionID) + c.sessionsMux.Unlock() + } session.registerTools(config.Tools) session.registerPermissionHandler(config.OnPermissionRequest) if config.OnUserInputRequest != nil { @@ -896,6 +976,7 @@ func (c *Client) DeleteSession(ctx context.Context, sessionID string) error { // Remove from local sessions map if present c.sessionsMux.Lock() delete(c.sessions, sessionID) + c.removeChildMappingsForParentLocked(sessionID) c.sessionsMux.Unlock() return nil @@ -1536,21 +1617,180 @@ func (c *Client) handleSessionEvent(req sessionEventRequest) { c.sessionsMux.Unlock() if ok { + // Intercept subagent lifecycle events for child tracking + c.handleSubagentEvent(req.SessionID, req.Event) + + // V3 broadcast allowlist enforcement: if this is an external_tool.requested + // event originating from a child session, enforce the tool allowlist. + if req.Event.Type == SessionEventTypeExternalToolRequested { + childSessionID := derefStr(req.Event.Data.SessionID) + toolName := derefStr(req.Event.Data.ToolName) + if childSessionID != "" && toolName != "" { + c.sessionsMux.Lock() + _, isChild := c.childToParent[childSessionID] + c.sessionsMux.Unlock() + if isChild && !c.isToolAllowedForChild(childSessionID, toolName) { + requestID := derefStr(req.Event.Data.RequestID) + if requestID != "" { + session.denyToolCallBroadcast(requestID, toolName) + } + return + } + } + } + session.dispatchEvent(req.Event) } } +// handleSubagentEvent intercepts subagent lifecycle events to manage child session tracking. +func (c *Client) handleSubagentEvent(parentSessionID string, event SessionEvent) { + switch event.Type { + case SessionEventTypeSubagentStarted: + c.onSubagentStarted(parentSessionID, event) + case SessionEventTypeSubagentCompleted, SessionEventTypeSubagentFailed: + c.onSubagentEnded(parentSessionID, event) + } +} + +// onSubagentStarted handles a subagent.started event by creating a subagent instance +// and mapping the child session to its parent. +func (c *Client) onSubagentStarted(parentSessionID string, event SessionEvent) { + toolCallID := derefStr(event.Data.ToolCallID) + agentName := derefStr(event.Data.AgentName) + childSessionID := derefStr(event.Data.RemoteSessionID) + + c.sessionsMux.Lock() + defer c.sessionsMux.Unlock() + + // Track instance by toolCallID (unique per launch) + if c.subagentInstances[parentSessionID] == nil { + c.subagentInstances[parentSessionID] = make(map[string]*subagentInstance) + } + c.subagentInstances[parentSessionID][toolCallID] = &subagentInstance{ + agentName: agentName, + toolCallID: toolCallID, + childSessionID: childSessionID, + startedAt: event.Timestamp, + } + + // Eagerly map child→parent and child→agent + if childSessionID != "" { + c.childToParent[childSessionID] = parentSessionID + c.childToAgent[childSessionID] = agentName + } +} + +// onSubagentEnded handles subagent.completed and subagent.failed events +// by removing the subagent instance. Child-to-parent mappings are NOT removed +// here because in-flight requests may still arrive after the subagent completes. +func (c *Client) onSubagentEnded(parentSessionID string, event SessionEvent) { + toolCallID := derefStr(event.Data.ToolCallID) + + c.sessionsMux.Lock() + defer c.sessionsMux.Unlock() + + if instances, ok := c.subagentInstances[parentSessionID]; ok { + delete(instances, toolCallID) + if len(instances) == 0 { + delete(c.subagentInstances, parentSessionID) + } + } +} + +// derefStr safely dereferences a string pointer, returning "" if nil. +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} + +// resolveSession looks up a session by ID. If the ID is not a directly +// registered session, it checks whether it is a known child session and +// returns the parent session instead. +// +// Returns (session, isChild, error). isChild=true means the request came +// from a child session and was resolved via parent lineage. +// +// Lock contract: acquires and releases sessionsMux internally. +// Does NOT hold sessionsMux when returning. +func (c *Client) resolveSession(sessionID string) (*Session, bool, error) { + c.sessionsMux.Lock() + // Direct lookup + if session, ok := c.sessions[sessionID]; ok { + c.sessionsMux.Unlock() + return session, false, nil + } + // Child→parent lookup (authoritative mapping only) + parentID, isChild := c.childToParent[sessionID] + if !isChild { + c.sessionsMux.Unlock() + return nil, false, fmt.Errorf("unknown session %s", sessionID) + } + session, ok := c.sessions[parentID] + c.sessionsMux.Unlock() + if !ok { + return nil, false, fmt.Errorf("parent session %s for child %s not found", parentID, sessionID) + } + return session, true, nil +} + +// removeChildMappingsForParentLocked removes all child mappings for a parent session. +// MUST be called with sessionsMux held. +func (c *Client) removeChildMappingsForParentLocked(parentSessionID string) { + for childID, parentID := range c.childToParent { + if parentID == parentSessionID { + delete(c.childToParent, childID) + delete(c.childToAgent, childID) + } + } + delete(c.subagentInstances, parentSessionID) +} + +// isToolAllowedForChild checks whether a tool is in the allowlist for the agent +// that owns the given child session. +func (c *Client) isToolAllowedForChild(childSessionID, toolName string) bool { + c.sessionsMux.Lock() + agentName, ok := c.childToAgent[childSessionID] + c.sessionsMux.Unlock() + if !ok { + return false // unknown child → deny + } + + session, _, _ := c.resolveSession(childSessionID) + if session == nil { + return false + } + + agentConfig := session.getAgentConfig(agentName) + if agentConfig == nil { + return false // agent not found → deny + } + + // nil Tools = all tools allowed + if agentConfig.Tools == nil { + return true + } + + // Explicit list — check membership + for _, t := range agentConfig.Tools { + if t == toolName { + return true + } + } + return false +} + // handleUserInputRequest handles a user input request from the CLI server. func (c *Client) handleUserInputRequest(req userInputRequest) (*userInputResponse, *jsonrpc2.Error) { if req.SessionID == "" || req.Question == "" { return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid user input request payload"} } - c.sessionsMux.Lock() - session, ok := c.sessions[req.SessionID] - c.sessionsMux.Unlock() - if !ok { - return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} + session, _, err := c.resolveSession(req.SessionID) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: err.Error()} } response, err := session.handleUserInputRequest(UserInputRequest{ @@ -1571,11 +1811,9 @@ func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jso return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid hooks invoke payload"} } - c.sessionsMux.Lock() - session, ok := c.sessions[req.SessionID] - c.sessionsMux.Unlock() - if !ok { - return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} + session, _, err := c.resolveSession(req.SessionID) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: err.Error()} } output, err := session.handleHooksInvoke(req.Type, req.Input) @@ -1646,11 +1884,19 @@ func (c *Client) handleToolCallRequestV2(req toolCallRequestV2) (*toolCallRespon return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid tool call payload"} } - c.sessionsMux.Lock() - session, ok := c.sessions[req.SessionID] - c.sessionsMux.Unlock() - if !ok { - return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} + session, isChild, err := c.resolveSession(req.SessionID) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: err.Error()} + } + + // For child sessions, enforce tool allowlist + if isChild && !c.isToolAllowedForChild(req.SessionID, req.ToolName) { + return &toolCallResponseV2{Result: ToolResult{ + TextResultForLLM: fmt.Sprintf("Tool '%s' is not supported by this client instance.", req.ToolName), + ResultType: "failure", + Error: fmt.Sprintf("tool '%s' not supported", req.ToolName), + ToolTelemetry: map[string]any{}, + }}, nil } handler, ok := session.getToolHandler(req.ToolName) @@ -1692,11 +1938,9 @@ func (c *Client) handlePermissionRequestV2(req permissionRequestV2) (*permission return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid permission request payload"} } - c.sessionsMux.Lock() - session, ok := c.sessions[req.SessionID] - c.sessionsMux.Unlock() - if !ok { - return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} + session, _, err := c.resolveSession(req.SessionID) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32602, Message: err.Error()} } handler := session.getPermissionHandler() diff --git a/go/client_subagent_test.go b/go/client_subagent_test.go new file mode 100644 index 000000000..d79b3a5cd --- /dev/null +++ b/go/client_subagent_test.go @@ -0,0 +1,977 @@ +package copilot + +import ( + "encoding/json" + "io" + "strings" + "sync" + "testing" + "time" + + "github.com/github/copilot-sdk/go/internal/jsonrpc2" + "github.com/github/copilot-sdk/go/rpc" +) + +// newTestClient creates a minimal test client with initialized maps. +func newTestClient() *Client { + return &Client{ + sessions: make(map[string]*Session), + childToParent: make(map[string]string), + childToAgent: make(map[string]string), + subagentInstances: make(map[string]map[string]*subagentInstance), + } +} + +// newSubagentTestSession creates a minimal test session with tools and agents. +func newSubagentTestSession(id string, tools []Tool, agents []CustomAgentConfig) *Session { + s := &Session{ + SessionID: id, + toolHandlers: make(map[string]ToolHandler), + customAgents: agents, + } + for _, t := range tools { + if t.Name != "" && t.Handler != nil { + s.toolHandlers[t.Name] = t.Handler + } + } + return s +} + +func strPtr(s string) *string { return &s } + +func testToolHandler(inv ToolInvocation) (ToolResult, error) { + return ToolResult{TextResultForLLM: "ok", ResultType: "success"}, nil +} + +// --------------------------------------------------------------------------- +// TestResolveSession +// --------------------------------------------------------------------------- + +func TestResolveSession(t *testing.T) { + t.Run("direct_session_returns_session", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent-1", nil, nil) + c.sessions["parent-1"] = parent + + session, isChild, err := c.resolveSession("parent-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if isChild { + t.Fatal("expected isChild=false for direct session") + } + if session != parent { + t.Fatal("returned session does not match registered session") + } + }) + + t.Run("child_session_returns_parent", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent-1", nil, nil) + c.sessions["parent-1"] = parent + c.childToParent["child-1"] = "parent-1" + + session, isChild, err := c.resolveSession("child-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !isChild { + t.Fatal("expected isChild=true for child session") + } + if session != parent { + t.Fatal("returned session should be the parent session") + } + }) + + t.Run("unknown_session_returns_error", func(t *testing.T) { + c := newTestClient() + + session, isChild, err := c.resolveSession("nonexistent") + if err == nil { + t.Fatal("expected error for unknown session") + } + if !strings.Contains(err.Error(), "unknown session") { + t.Fatalf("error should contain 'unknown session', got: %v", err) + } + if isChild { + t.Fatal("expected isChild=false") + } + if session != nil { + t.Fatal("expected nil session") + } + }) + + t.Run("child_of_deleted_parent_returns_error", func(t *testing.T) { + c := newTestClient() + c.childToParent["child-1"] = "parent-1" + // parent-1 is NOT registered in c.sessions + + session, isChild, err := c.resolveSession("child-1") + if err == nil { + t.Fatal("expected error when parent session is missing") + } + if !strings.Contains(err.Error(), "parent session") { + t.Fatalf("error should contain 'parent session', got: %v", err) + } + if isChild { + t.Fatal("expected isChild=false on error path") + } + if session != nil { + t.Fatal("expected nil session") + } + }) +} + +// --------------------------------------------------------------------------- +// TestChildToolAllowlist +// --------------------------------------------------------------------------- + +func TestChildToolAllowlist(t *testing.T) { + setup := func(tools []string) *Client { + c := newTestClient() + agents := []CustomAgentConfig{{Name: "test-agent", Tools: tools}} + parent := newSubagentTestSession("parent-1", []Tool{ + {Name: "save_output", Handler: testToolHandler}, + {Name: "other_tool", Handler: testToolHandler}, + }, agents) + c.sessions["parent-1"] = parent + c.childToParent["child-1"] = "parent-1" + c.childToAgent["child-1"] = "test-agent" + return c + } + + t.Run("nil_tools_allows_all", func(t *testing.T) { + c := setup(nil) // nil Tools = all allowed + if !c.isToolAllowedForChild("child-1", "save_output") { + t.Fatal("nil Tools should allow save_output") + } + if !c.isToolAllowedForChild("child-1", "other_tool") { + t.Fatal("nil Tools should allow other_tool") + } + if !c.isToolAllowedForChild("child-1", "any_random_tool") { + t.Fatal("nil Tools should allow any tool") + } + }) + + t.Run("explicit_list_allows_listed_tool", func(t *testing.T) { + c := setup([]string{"save_output"}) + if !c.isToolAllowedForChild("child-1", "save_output") { + t.Fatal("save_output should be allowed") + } + }) + + t.Run("explicit_list_blocks_unlisted_tool", func(t *testing.T) { + c := setup([]string{"save_output"}) + if c.isToolAllowedForChild("child-1", "other_tool") { + t.Fatal("other_tool should be blocked") + } + }) + + t.Run("empty_tools_blocks_all", func(t *testing.T) { + c := setup([]string{}) // empty = block all + if c.isToolAllowedForChild("child-1", "save_output") { + t.Fatal("empty Tools should block save_output") + } + if c.isToolAllowedForChild("child-1", "other_tool") { + t.Fatal("empty Tools should block other_tool") + } + }) +} + +// --------------------------------------------------------------------------- +// TestSubagentInstanceTracking +// --------------------------------------------------------------------------- + +func TestSubagentInstanceTracking(t *testing.T) { + makeEvent := func(evType SessionEventType, toolCallID, agentName, childSessionID string) SessionEvent { + return SessionEvent{ + Type: evType, + Timestamp: time.Now(), + Data: Data{ + ToolCallID: strPtr(toolCallID), + AgentName: strPtr(agentName), + RemoteSessionID: strPtr(childSessionID), + }, + } + } + + t.Run("started_creates_instance", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent-1", nil, nil) + c.sessions["parent-1"] = parent + + event := makeEvent(SessionEventTypeSubagentStarted, "tc-1", "my-agent", "child-session-1") + c.onSubagentStarted("parent-1", event) + + // Verify subagentInstances + instances, ok := c.subagentInstances["parent-1"] + if !ok { + t.Fatal("expected subagentInstances entry for parent-1") + } + inst, ok := instances["tc-1"] + if !ok { + t.Fatal("expected instance with toolCallID tc-1") + } + if inst.agentName != "my-agent" { + t.Fatalf("expected agentName 'my-agent', got %q", inst.agentName) + } + if inst.childSessionID != "child-session-1" { + t.Fatalf("expected childSessionID 'child-session-1', got %q", inst.childSessionID) + } + + // Verify child mappings + if c.childToParent["child-session-1"] != "parent-1" { + t.Fatal("childToParent mapping not set") + } + if c.childToAgent["child-session-1"] != "my-agent" { + t.Fatal("childToAgent mapping not set") + } + }) + + t.Run("completed_removes_instance", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent-1", nil, nil) + c.sessions["parent-1"] = parent + + startEvent := makeEvent(SessionEventTypeSubagentStarted, "tc-1", "my-agent", "child-session-1") + c.onSubagentStarted("parent-1", startEvent) + + endEvent := makeEvent(SessionEventTypeSubagentCompleted, "tc-1", "my-agent", "child-session-1") + c.onSubagentEnded("parent-1", endEvent) + + // Instance removed + if instances, ok := c.subagentInstances["parent-1"]; ok && len(instances) > 0 { + t.Fatal("expected instance to be removed after completion") + } + + // Child mappings preserved for in-flight requests + if c.childToParent["child-session-1"] != "parent-1" { + t.Fatal("childToParent should be preserved after subagent completion") + } + if c.childToAgent["child-session-1"] != "my-agent" { + t.Fatal("childToAgent should be preserved after subagent completion") + } + }) + + t.Run("concurrent_same_agent_tracked_independently", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent-1", nil, nil) + c.sessions["parent-1"] = parent + + // Two launches of the same agent with different toolCallIDs + event1 := makeEvent(SessionEventTypeSubagentStarted, "tc-1", "my-agent", "child-1") + event2 := makeEvent(SessionEventTypeSubagentStarted, "tc-2", "my-agent", "child-2") + c.onSubagentStarted("parent-1", event1) + c.onSubagentStarted("parent-1", event2) + + instances := c.subagentInstances["parent-1"] + if len(instances) != 2 { + t.Fatalf("expected 2 instances, got %d", len(instances)) + } + + // Complete one + endEvent := makeEvent(SessionEventTypeSubagentCompleted, "tc-1", "my-agent", "child-1") + c.onSubagentEnded("parent-1", endEvent) + + instances = c.subagentInstances["parent-1"] + if len(instances) != 1 { + t.Fatalf("expected 1 instance remaining, got %d", len(instances)) + } + if _, ok := instances["tc-2"]; !ok { + t.Fatal("tc-2 should still be tracked") + } + }) +} + +// --------------------------------------------------------------------------- +// TestRequestHandlerResolution +// --------------------------------------------------------------------------- + +func TestRequestHandlerResolution(t *testing.T) { + t.Run("tool_call_resolves_child_session", func(t *testing.T) { + c := newTestClient() + agents := []CustomAgentConfig{{Name: "test-agent", Tools: nil}} // nil = all tools + parent := newSubagentTestSession("parent-1", []Tool{ + {Name: "my_tool", Handler: testToolHandler}, + }, agents) + c.sessions["parent-1"] = parent + c.childToParent["child-1"] = "parent-1" + c.childToAgent["child-1"] = "test-agent" + + resp, rpcErr := c.handleToolCallRequestV2(toolCallRequestV2{ + SessionID: "child-1", + ToolCallID: "tc-1", + ToolName: "my_tool", + Arguments: map[string]any{}, + }) + if rpcErr != nil { + t.Fatalf("unexpected RPC error: %v", rpcErr.Message) + } + if resp.Result.ResultType != "success" { + t.Fatalf("expected success result, got %q", resp.Result.ResultType) + } + if resp.Result.TextResultForLLM != "ok" { + t.Fatalf("expected 'ok', got %q", resp.Result.TextResultForLLM) + } + }) + + t.Run("permission_request_resolves_child_session", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent-1", nil, nil) + parent.permissionHandler = func(req PermissionRequest, inv PermissionInvocation) (PermissionRequestResult, error) { + return PermissionRequestResult{Kind: "approved"}, nil + } + c.sessions["parent-1"] = parent + c.childToParent["child-1"] = "parent-1" + c.childToAgent["child-1"] = "test-agent" + + resp, rpcErr := c.handlePermissionRequestV2(permissionRequestV2{ + SessionID: "child-1", + Request: PermissionRequest{Kind: "file_write"}, + }) + if rpcErr != nil { + t.Fatalf("unexpected RPC error: %v", rpcErr.Message) + } + if resp.Result.Kind != "approved" { + t.Fatalf("expected 'approved', got %q", resp.Result.Kind) + } + }) + + t.Run("user_input_resolves_child_session", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent-1", nil, nil) + parent.userInputHandler = func(req UserInputRequest, inv UserInputInvocation) (UserInputResponse, error) { + return UserInputResponse{Answer: "test-answer"}, nil + } + c.sessions["parent-1"] = parent + c.childToParent["child-1"] = "parent-1" + c.childToAgent["child-1"] = "test-agent" + + resp, rpcErr := c.handleUserInputRequest(userInputRequest{ + SessionID: "child-1", + Question: "What is your name?", + }) + if rpcErr != nil { + t.Fatalf("unexpected RPC error: %v", rpcErr.Message) + } + if resp.Answer != "test-answer" { + t.Fatalf("expected 'test-answer', got %q", resp.Answer) + } + }) + + t.Run("hooks_invoke_resolves_child_session", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent-1", nil, nil) + parent.hooks = &SessionHooks{ + OnPreToolUse: func(input PreToolUseHookInput, inv HookInvocation) (*PreToolUseHookOutput, error) { + return &PreToolUseHookOutput{PermissionDecision: "allow"}, nil + }, + } + c.sessions["parent-1"] = parent + c.childToParent["child-1"] = "parent-1" + c.childToAgent["child-1"] = "test-agent" + + hookInput, _ := json.Marshal(PreToolUseHookInput{ + Timestamp: time.Now().Unix(), + Cwd: "/tmp", + ToolName: "some_tool", + }) + + result, rpcErr := c.handleHooksInvoke(hooksInvokeRequest{ + SessionID: "child-1", + Type: "preToolUse", + Input: json.RawMessage(hookInput), + }) + if rpcErr != nil { + t.Fatalf("unexpected RPC error: %v", rpcErr.Message) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result["output"] == nil { + t.Fatal("expected output in result") + } + }) + + t.Run("tool_call_child_denied_tool_returns_unsupported", func(t *testing.T) { + c := newTestClient() + agents := []CustomAgentConfig{{Name: "test-agent", Tools: []string{"allowed_tool"}}} + parent := newSubagentTestSession("parent-1", []Tool{ + {Name: "allowed_tool", Handler: testToolHandler}, + {Name: "denied_tool", Handler: testToolHandler}, + }, agents) + c.sessions["parent-1"] = parent + c.childToParent["child-1"] = "parent-1" + c.childToAgent["child-1"] = "test-agent" + + resp, rpcErr := c.handleToolCallRequestV2(toolCallRequestV2{ + SessionID: "child-1", + ToolCallID: "tc-1", + ToolName: "denied_tool", + Arguments: map[string]any{}, + }) + // Should NOT return an RPC error — returns an unsupported tool result instead + if rpcErr != nil { + t.Fatalf("should not return RPC error for denied tool, got: %v", rpcErr.Message) + } + if resp.Result.ResultType != "failure" { + t.Fatalf("expected failure result, got %q", resp.Result.ResultType) + } + if !strings.Contains(resp.Result.TextResultForLLM, "not supported") { + t.Fatalf("expected 'not supported' message, got %q", resp.Result.TextResultForLLM) + } + }) +} + +// --------------------------------------------------------------------------- +// TestCleanup +// --------------------------------------------------------------------------- + +func TestCleanup(t *testing.T) { + t.Run("stop_clears_all_maps", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent-1", nil, nil) + c.sessions["parent-1"] = parent + c.childToParent["child-1"] = "parent-1" + c.childToAgent["child-1"] = "test-agent" + c.subagentInstances["parent-1"] = map[string]*subagentInstance{ + "tc-1": {agentName: "test-agent", toolCallID: "tc-1"}, + } + + // Simulate cleanup (Stop() does RPC + map clearing; we test removeChildMappingsForParentLocked + manual clear) + c.sessionsMux.Lock() + c.removeChildMappingsForParentLocked("parent-1") + delete(c.sessions, "parent-1") + c.sessionsMux.Unlock() + + if len(c.childToParent) != 0 { + t.Fatal("childToParent should be empty") + } + if len(c.childToAgent) != 0 { + t.Fatal("childToAgent should be empty") + } + if len(c.subagentInstances) != 0 { + t.Fatal("subagentInstances should be empty") + } + if len(c.sessions) != 0 { + t.Fatal("sessions should be empty") + } + }) + + t.Run("delete_session_clears_only_target_children", func(t *testing.T) { + c := newTestClient() + parentA := newSubagentTestSession("parent-A", nil, nil) + parentB := newSubagentTestSession("parent-B", nil, nil) + c.sessions["parent-A"] = parentA + c.sessions["parent-B"] = parentB + c.childToParent["child-A1"] = "parent-A" + c.childToParent["child-A2"] = "parent-A" + c.childToParent["child-B1"] = "parent-B" + c.childToAgent["child-A1"] = "agent-a" + c.childToAgent["child-A2"] = "agent-a" + c.childToAgent["child-B1"] = "agent-b" + c.subagentInstances["parent-A"] = map[string]*subagentInstance{ + "tc-a1": {agentName: "agent-a"}, + } + c.subagentInstances["parent-B"] = map[string]*subagentInstance{ + "tc-b1": {agentName: "agent-b"}, + } + + c.sessionsMux.Lock() + c.removeChildMappingsForParentLocked("parent-A") + c.sessionsMux.Unlock() + + // parent-A children removed + if _, ok := c.childToParent["child-A1"]; ok { + t.Fatal("child-A1 should be removed") + } + if _, ok := c.childToParent["child-A2"]; ok { + t.Fatal("child-A2 should be removed") + } + if _, ok := c.subagentInstances["parent-A"]; ok { + t.Fatal("parent-A subagentInstances should be removed") + } + + // parent-B children intact + if c.childToParent["child-B1"] != "parent-B" { + t.Fatal("child-B1 mapping should still exist") + } + if c.childToAgent["child-B1"] != "agent-b" { + t.Fatal("child-B1 agent mapping should still exist") + } + if _, ok := c.subagentInstances["parent-B"]; !ok { + t.Fatal("parent-B subagentInstances should still exist") + } + }) + + t.Run("destroy_session_clears_children_via_callback", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent-1", nil, nil) + c.sessions["parent-1"] = parent + c.childToParent["child-1"] = "parent-1" + c.childToAgent["child-1"] = "test-agent" + c.subagentInstances["parent-1"] = map[string]*subagentInstance{ + "tc-1": {agentName: "test-agent"}, + } + + // Set up onDestroy callback (mirrors Client's real onDestroy which only clears child mappings) + parent.onDestroy = func() { + c.sessionsMux.Lock() + defer c.sessionsMux.Unlock() + c.removeChildMappingsForParentLocked("parent-1") + } + + // Call onDestroy + parent.onDestroy() + + if len(c.childToParent) != 0 { + t.Fatal("childToParent should be cleared by onDestroy") + } + if len(c.childToAgent) != 0 { + t.Fatal("childToAgent should be cleared by onDestroy") + } + if len(c.subagentInstances) != 0 { + t.Fatal("subagentInstances should be cleared by onDestroy") + } + // Session itself is NOT removed by onDestroy (that's Destroy()'s job via RPC) + if _, ok := c.sessions["parent-1"]; !ok { + t.Fatal("session should still exist after onDestroy (only child mappings cleared)") + } + }) +} + +// --------------------------------------------------------------------------- +// TestSessionIsolation +// --------------------------------------------------------------------------- + +func TestSessionIsolation(t *testing.T) { + t.Run("child_cannot_reach_other_parent", func(t *testing.T) { + c := newTestClient() + parentA := newSubagentTestSession("parent-A", nil, nil) + parentB := newSubagentTestSession("parent-B", nil, nil) + c.sessions["parent-A"] = parentA + c.sessions["parent-B"] = parentB + c.childToParent["child-A"] = "parent-A" + + session, isChild, err := c.resolveSession("child-A") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !isChild { + t.Fatal("expected isChild=true") + } + if session != parentA { + t.Fatal("child-A should resolve to parent-A, not parent-B") + } + if session == parentB { + t.Fatal("child-A must not resolve to parent-B") + } + }) + + t.Run("child_session_id_immutable_mapping", func(t *testing.T) { + c := newTestClient() + parentA := newSubagentTestSession("parent-A", nil, nil) + c.sessions["parent-A"] = parentA + c.childToParent["child-1"] = "parent-A" + + // Resolve multiple times — always gets parent-A + for i := 0; i < 5; i++ { + session, isChild, err := c.resolveSession("child-1") + if err != nil { + t.Fatalf("iteration %d: unexpected error: %v", i, err) + } + if !isChild { + t.Fatalf("iteration %d: expected isChild=true", i) + } + if session != parentA { + t.Fatalf("iteration %d: mapping should consistently resolve to parent-A", i) + } + } + }) +} + +// --------------------------------------------------------------------------- +// TestConcurrency +// --------------------------------------------------------------------------- + +func TestConcurrency(t *testing.T) { + t.Run("concurrent_resolve_session_safe", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent", nil, nil) + c.sessions["parent"] = parent + c.childToParent["child-1"] = "parent" + c.childToAgent["child-1"] = "agent" + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c.resolveSession("parent") + c.resolveSession("child-1") + c.resolveSession("nonexistent") + }() + } + wg.Wait() + }) + + t.Run("concurrent_subagent_events_safe", func(t *testing.T) { + c := newTestClient() + parent := newSubagentTestSession("parent", nil, nil) + c.sessions["parent"] = parent + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + tcID := "tc-" + strings.Repeat("x", idx%10) + childID := "child-" + strings.Repeat("x", idx%10) + event := SessionEvent{ + Type: SessionEventTypeSubagentStarted, + Timestamp: time.Now(), + Data: Data{ + ToolCallID: strPtr(tcID), + AgentName: strPtr("agent"), + RemoteSessionID: strPtr(childID), + }, + } + c.handleSubagentEvent("parent", event) + + // Also try resolving concurrently + c.resolveSession(childID) + c.isToolAllowedForChild(childID, "some_tool") + + endEvent := SessionEvent{ + Type: SessionEventTypeSubagentCompleted, + Timestamp: time.Now(), + Data: Data{ + ToolCallID: strPtr(tcID), + }, + } + c.handleSubagentEvent("parent", endEvent) + }(i) + } + wg.Wait() + }) +} + +// newTestSessionWithRPC creates a test session with a buffered eventCh and a +// stub RPC layer whose underlying process is already "exited". Any RPC call +// (e.g. denyToolCallBroadcast) returns immediately with an error instead of +// blocking, which is safe because callers ignore the error. +func newTestSessionWithRPC(id string, agents []CustomAgentConfig) *Session { + s := &Session{ + SessionID: id, + toolHandlers: make(map[string]ToolHandler), + customAgents: agents, + eventCh: make(chan SessionEvent, 10), + } + pr, pw := io.Pipe() + rpcClient := jsonrpc2.NewClient(pw, pr) + done := make(chan struct{}) + close(done) + rpcClient.SetProcessDone(done, nil) + s.RPC = rpc.NewSessionRpc(rpcClient, id) + return s +} + +// --------------------------------------------------------------------------- +// TestV3BroadcastAllowlistEnforcement +// --------------------------------------------------------------------------- + +func TestV3BroadcastAllowlistEnforcement(t *testing.T) { + t.Run("denied tool for child session does not dispatch", func(t *testing.T) { + c := newTestClient() + agents := []CustomAgentConfig{{Name: "test-agent", Tools: []string{"allowed_tool"}}} + parent := newTestSessionWithRPC("parent-1", agents) + c.sessions["parent-1"] = parent + + // Register child via onSubagentStarted + startEvent := SessionEvent{ + Type: SessionEventTypeSubagentStarted, + Timestamp: time.Now(), + Data: Data{ + ToolCallID: strPtr("tc-1"), + AgentName: strPtr("test-agent"), + RemoteSessionID: strPtr("child-1"), + }, + } + c.onSubagentStarted("parent-1", startEvent) + + // Invoke handleSessionEvent with a denied tool + c.handleSessionEvent(sessionEventRequest{ + SessionID: "parent-1", + Event: SessionEvent{ + Type: SessionEventTypeExternalToolRequested, + Data: Data{ + SessionID: strPtr("child-1"), + ToolName: strPtr("denied_tool"), + RequestID: strPtr("req-1"), + }, + }, + }) + + // eventCh should be empty — dispatchEvent was never called + select { + case ev := <-parent.eventCh: + t.Fatalf("expected no dispatched event, got %v", ev.Type) + default: + } + }) + + t.Run("allowed tool for child session dispatches normally", func(t *testing.T) { + c := newTestClient() + agents := []CustomAgentConfig{{Name: "test-agent", Tools: []string{"allowed_tool"}}} + // No tool handlers registered — broadcast goroutine finds no handler and returns. + parent := newTestSessionWithRPC("parent-1", agents) + c.sessions["parent-1"] = parent + + startEvent := SessionEvent{ + Type: SessionEventTypeSubagentStarted, + Timestamp: time.Now(), + Data: Data{ + ToolCallID: strPtr("tc-1"), + AgentName: strPtr("test-agent"), + RemoteSessionID: strPtr("child-1"), + }, + } + c.onSubagentStarted("parent-1", startEvent) + + c.handleSessionEvent(sessionEventRequest{ + SessionID: "parent-1", + Event: SessionEvent{ + Type: SessionEventTypeExternalToolRequested, + Data: Data{ + SessionID: strPtr("child-1"), + ToolName: strPtr("allowed_tool"), + RequestID: strPtr("req-2"), + }, + }, + }) + + select { + case ev := <-parent.eventCh: + if ev.Type != SessionEventTypeExternalToolRequested { + t.Fatalf("expected external_tool.requested, got %v", ev.Type) + } + case <-time.After(time.Second): + t.Fatal("expected event to be dispatched but eventCh was empty") + } + }) + + t.Run("parent session tool event always dispatches", func(t *testing.T) { + c := newTestClient() + agents := []CustomAgentConfig{{Name: "test-agent", Tools: []string{"allowed_tool"}}} + parent := newTestSessionWithRPC("parent-1", agents) + c.sessions["parent-1"] = parent + + // Register a child so the child-to-parent map is populated, but use the + // parent's session ID in the event so the request is NOT treated as a child. + startEvent := SessionEvent{ + Type: SessionEventTypeSubagentStarted, + Timestamp: time.Now(), + Data: Data{ + ToolCallID: strPtr("tc-1"), + AgentName: strPtr("test-agent"), + RemoteSessionID: strPtr("child-1"), + }, + } + c.onSubagentStarted("parent-1", startEvent) + + // Event with SessionID = parent (not a child) — should always dispatch. + c.handleSessionEvent(sessionEventRequest{ + SessionID: "parent-1", + Event: SessionEvent{ + Type: SessionEventTypeExternalToolRequested, + Data: Data{ + SessionID: strPtr("parent-1"), + ToolName: strPtr("denied_tool"), + RequestID: strPtr("req-3"), + }, + }, + }) + + select { + case ev := <-parent.eventCh: + if ev.Type != SessionEventTypeExternalToolRequested { + t.Fatalf("expected external_tool.requested, got %v", ev.Type) + } + case <-time.After(time.Second): + t.Fatal("expected event to be dispatched for parent session but eventCh was empty") + } + }) +} + +// --------------------------------------------------------------------------- +// TestToolAllowlist_EmptyToolsList +// --------------------------------------------------------------------------- + +func TestToolAllowlist_EmptyToolsList(t *testing.T) { + t.Run("agent with empty Tools denies all tools", func(t *testing.T) { + c := newTestClient() + agents := []CustomAgentConfig{{Name: "test-agent", Tools: []string{}}} + parent := newSubagentTestSession("parent-1", nil, agents) + c.sessions["parent-1"] = parent + c.childToParent["child-1"] = "parent-1" + c.childToAgent["child-1"] = "test-agent" + + for _, tool := range []string{"allowed_tool", "any_tool", "save_output", ""} { + if c.isToolAllowedForChild("child-1", tool) { + t.Fatalf("empty Tools list should deny %q", tool) + } + } + }) +} + +// --------------------------------------------------------------------------- +// TestEnrichAgentToolDefinitions +// --------------------------------------------------------------------------- + +func TestEnrichAgentToolDefinitions(t *testing.T) { + t.Run("populates definitions for matching tools", func(t *testing.T) { + sessionTools := []Tool{ + {Name: "tool_a", Description: "desc_a", Parameters: map[string]any{"type": "object"}, Handler: testToolHandler}, + {Name: "tool_b", Description: "desc_b", Parameters: map[string]any{"type": "string"}, Handler: testToolHandler}, + } + agents := []CustomAgentConfig{ + {Name: "agent1", Tools: []string{"tool_a"}}, + } + + enrichAgentToolDefinitions(agents, sessionTools) + + if len(agents[0].ToolDefinitions) != 1 { + t.Fatalf("expected 1 tool definition, got %d", len(agents[0].ToolDefinitions)) + } + def := agents[0].ToolDefinitions[0] + if def.Name != "tool_a" { + t.Errorf("expected Name=tool_a, got %s", def.Name) + } + if def.Description != "desc_a" { + t.Errorf("expected Description=desc_a, got %s", def.Description) + } + if def.Parameters["type"] != "object" { + t.Errorf("expected Parameters[type]=object, got %v", def.Parameters["type"]) + } + if def.Handler != nil { + t.Error("Handler should not be copied into ToolDefinitions") + } + + // Verify Handler is excluded from wire format via json:"-" + data, err := json.Marshal(def) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + if strings.Contains(string(data), "handler") { + t.Errorf("wire format should not contain handler field, got: %s", data) + } + }) + + t.Run("skips agents with nil tools", func(t *testing.T) { + sessionTools := []Tool{ + {Name: "tool_a", Description: "desc_a", Handler: testToolHandler}, + } + agents := []CustomAgentConfig{ + {Name: "agent1", Tools: nil}, + } + + enrichAgentToolDefinitions(agents, sessionTools) + + if agents[0].ToolDefinitions != nil { + t.Errorf("expected nil ToolDefinitions for nil Tools, got %v", agents[0].ToolDefinitions) + } + }) + + t.Run("skips agents with empty tools list", func(t *testing.T) { + sessionTools := []Tool{ + {Name: "tool_a", Description: "desc_a", Handler: testToolHandler}, + } + agents := []CustomAgentConfig{ + {Name: "agent1", Tools: []string{}}, + } + + enrichAgentToolDefinitions(agents, sessionTools) + + if agents[0].ToolDefinitions != nil { + t.Errorf("expected nil ToolDefinitions for empty Tools, got %v", agents[0].ToolDefinitions) + } + }) + + t.Run("handles missing tool names gracefully", func(t *testing.T) { + sessionTools := []Tool{ + {Name: "tool_a", Description: "desc_a", Handler: testToolHandler}, + } + agents := []CustomAgentConfig{ + {Name: "agent1", Tools: []string{"nonexistent_tool"}}, + } + + enrichAgentToolDefinitions(agents, sessionTools) + + if agents[0].ToolDefinitions != nil { + t.Errorf("expected nil ToolDefinitions when no tools match, got %v", agents[0].ToolDefinitions) + } + }) + + t.Run("handles multiple agents independently", func(t *testing.T) { + sessionTools := []Tool{ + {Name: "tool_a", Description: "desc_a", Parameters: map[string]any{"a": 1}, Handler: testToolHandler}, + {Name: "tool_b", Description: "desc_b", Parameters: map[string]any{"b": 2}, Handler: testToolHandler}, + {Name: "tool_c", Description: "desc_c", Parameters: map[string]any{"c": 3}, Handler: testToolHandler}, + } + agents := []CustomAgentConfig{ + {Name: "agent1", Tools: []string{"tool_a", "tool_b"}}, + {Name: "agent2", Tools: []string{"tool_c"}}, + {Name: "agent3", Tools: nil}, + } + + enrichAgentToolDefinitions(agents, sessionTools) + + // agent1: should have tool_a and tool_b + if len(agents[0].ToolDefinitions) != 2 { + t.Fatalf("agent1: expected 2 tool definitions, got %d", len(agents[0].ToolDefinitions)) + } + if agents[0].ToolDefinitions[0].Name != "tool_a" { + t.Errorf("agent1: expected first def=tool_a, got %s", agents[0].ToolDefinitions[0].Name) + } + if agents[0].ToolDefinitions[1].Name != "tool_b" { + t.Errorf("agent1: expected second def=tool_b, got %s", agents[0].ToolDefinitions[1].Name) + } + + // agent2: should have tool_c + if len(agents[1].ToolDefinitions) != 1 { + t.Fatalf("agent2: expected 1 tool definition, got %d", len(agents[1].ToolDefinitions)) + } + if agents[1].ToolDefinitions[0].Name != "tool_c" { + t.Errorf("agent2: expected def=tool_c, got %s", agents[1].ToolDefinitions[0].Name) + } + + // agent3: nil Tools → should remain nil + if agents[2].ToolDefinitions != nil { + t.Errorf("agent3: expected nil ToolDefinitions for nil Tools, got %v", agents[2].ToolDefinitions) + } + }) + + t.Run("does not mutate caller config", func(t *testing.T) { + sessionTools := []Tool{ + {Name: "tool_a", Description: "desc_a", Parameters: map[string]any{"type": "object"}, Handler: testToolHandler}, + } + originalAgents := []CustomAgentConfig{ + {Name: "agent1", Tools: []string{"tool_a"}}, + } + + // Simulate the copy-before-enrich pattern used in CreateSession + copied := make([]CustomAgentConfig, len(originalAgents)) + copy(copied, originalAgents) + enrichAgentToolDefinitions(copied, sessionTools) + + // The copy should have definitions + if len(copied[0].ToolDefinitions) != 1 { + t.Fatalf("copied agent should have 1 tool definition, got %d", len(copied[0].ToolDefinitions)) + } + + // The original should remain untouched + if originalAgents[0].ToolDefinitions != nil { + t.Errorf("original agent ToolDefinitions should still be nil, got %v", originalAgents[0].ToolDefinitions) + } + }) +} diff --git a/go/internal/e2e/subagent_tool_test.go b/go/internal/e2e/subagent_tool_test.go new file mode 100644 index 000000000..f0239d2ca --- /dev/null +++ b/go/internal/e2e/subagent_tool_test.go @@ -0,0 +1,135 @@ +//go:build integration + +package e2e + +import ( + "strings" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +// TestSubagentCustomTools requires a real CLI to test the full round-trip of +// subagent child sessions invoking custom tools registered on parent sessions. +// +// Run with: +// +// cd go && go test -tags integration -v ./internal/e2e -run TestSubagentCustomTools +// +// Prerequisites: +// - Copilot CLI installed (or COPILOT_CLI_PATH set) +// - Valid GitHub authentication configured +func TestSubagentCustomTools(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("subagent invokes parent custom tool", func(t *testing.T) { + ctx.ConfigureForTest(t) + + // Track tool invocations + toolInvoked := make(chan string, 1) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{ + copilot.DefineTool("save_result", "Saves a result string", + func(params struct { + Result string `json:"result" jsonschema:"The result to save"` + }, inv copilot.ToolInvocation) (string, error) { + toolInvoked <- params.Result + return "saved: " + params.Result, nil + }), + }, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "helper-agent", + DisplayName: "Helper Agent", + Description: "A helper agent that can save results using the save_result tool", + Tools: []string{"save_result"}, + Prompt: "You are a helper agent. When asked to save something, use the save_result tool.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Send a message that should trigger the subagent which invokes the custom tool + _, err = session.Send(t.Context(), copilot.MessageOptions{ + Prompt: "Use the helper-agent to save the result 'hello world'", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Wait for the tool to be invoked (with timeout) + select { + case result := <-toolInvoked: + if !strings.Contains(strings.ToLower(result), "hello world") { + t.Errorf("Expected tool to receive 'hello world', got %q", result) + } + case <-time.After(30 * time.Second): + t.Fatal("Timeout waiting for save_result tool invocation from subagent") + } + + // Get the final response + answer, err := testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + if answer.Data.Content == nil { + t.Fatal("Expected non-nil content in response") + } + t.Logf("Response: %s", *answer.Data.Content) + }) + + t.Run("subagent denied unlisted tool returns unsupported", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{ + copilot.DefineTool("allowed_tool", "An allowed tool", + func(params struct{}, inv copilot.ToolInvocation) (string, error) { + return "allowed", nil + }), + copilot.DefineTool("restricted_tool", "A restricted tool", + func(params struct{}, inv copilot.ToolInvocation) (string, error) { + t.Error("restricted_tool should not be invoked by subagent") + return "should not reach here", nil + }), + }, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "restricted-agent", + DisplayName: "Restricted Agent", + Description: "An agent with limited tool access", + Tools: []string{"allowed_tool"}, // restricted_tool NOT listed + Prompt: "You are a restricted agent. Try to use both allowed_tool and restricted_tool.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.Send(t.Context(), copilot.MessageOptions{ + Prompt: "Use the restricted-agent to invoke restricted_tool", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + answer, err := testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + if answer.Data.Content != nil { + t.Logf("Response: %s", *answer.Data.Content) + } + // The restricted_tool handler should NOT have been called (assertion in handler above) + }) +} diff --git a/go/session.go b/go/session.go index 04c1a05b0..70a6d2842 100644 --- a/go/session.go +++ b/go/session.go @@ -72,6 +72,8 @@ type Session struct { elicitationMu sync.RWMutex capabilities SessionCapabilities capabilitiesMu sync.RWMutex + onDestroy func() // set by Client when session is created; called by Destroy() + customAgents []CustomAgentConfig // agent configs from SessionConfig // eventCh serializes user event handler dispatch. dispatchEvent enqueues; // a single goroutine (processEvents) dequeues and invokes handlers in FIFO order. @@ -89,6 +91,17 @@ func (s *Session) WorkspacePath() string { return s.workspacePath } +// getAgentConfig returns the CustomAgentConfig for the given agent name, or nil if not found. +func (s *Session) getAgentConfig(agentName string) *CustomAgentConfig { + for i := range s.customAgents { + if s.customAgents[i].Name == agentName { + config := s.customAgents[i] + return &config + } + } + return nil +} + // newSession creates a new session wrapper with the given session ID and client. func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string) *Session { s := &Session{ @@ -993,6 +1006,23 @@ func (s *Session) handleBroadcastEvent(event SessionEvent) { } } +// denyToolCallBroadcast responds to a v3 broadcast tool request with a denial, +// used when a child session attempts to invoke a tool not in its allowlist. +func (s *Session) denyToolCallBroadcast(requestID, toolName string) { + errMsg := fmt.Sprintf("tool '%s' not supported", toolName) + resultType := "failure" + s.RPC.Tools.HandlePendingToolCall(context.Background(), + &rpc.SessionToolsHandlePendingToolCallParams{ + RequestID: requestID, + Error: &errMsg, + Result: &rpc.ResultUnion{ResultResult: &rpc.ResultResult{ + TextResultForLlm: fmt.Sprintf("Tool '%s' is not supported by this client instance.", toolName), + ResultType: &resultType, + Error: &errMsg, + }}, + }) +} + // executeToolAndRespond executes a tool handler and sends the result back via RPC. func (s *Session) executeToolAndRespond(requestID, toolName, toolCallID string, arguments any, handler ToolHandler, traceparent, tracestate string) { ctx := contextWithTraceParent(context.Background(), traceparent, tracestate) @@ -1181,6 +1211,10 @@ func (s *Session) Disconnect() error { s.elicitationHandler = nil s.elicitationMu.Unlock() + if s.onDestroy != nil { + s.onDestroy() + } + return nil } diff --git a/go/types.go b/go/types.go index 9f23dcb85..e70f514a6 100644 --- a/go/types.go +++ b/go/types.go @@ -410,6 +410,10 @@ type CustomAgentConfig struct { Description string `json:"description,omitempty"` // Tools is the list of tool names the agent can use (nil for all tools) Tools []string `json:"tools,omitempty"` + // ToolDefinitions holds full tool definitions for tools listed in Tools. + // Auto-populated by the SDK when creating/resuming sessions. + // The CLI can use these definitions to set up custom tools on child sessions. + ToolDefinitions []Tool `json:"toolDefinitions,omitempty"` // Prompt is the prompt content for the agent Prompt string `json:"prompt"` // MCPServers are MCP servers specific to this agent diff --git a/test/snapshots/subagent_tool/subagent_denied_unlisted_tool_returns_unsupported.yaml b/test/snapshots/subagent_tool/subagent_denied_unlisted_tool_returns_unsupported.yaml new file mode 100644 index 000000000..b963a2a1b --- /dev/null +++ b/test/snapshots/subagent_tool/subagent_denied_unlisted_tool_returns_unsupported.yaml @@ -0,0 +1,18 @@ +# Placeholder snapshot for subagent denied tool E2E test. +# This snapshot needs to be captured from a real CLI session. +# +# To capture: +# 1. Ensure COPILOT_CLI_PATH is set or CLI is installed +# 2. Run: cd go && go test -tags integration -v ./internal/e2e -run TestSubagentCustomTools +# 3. The proxy will capture the exchanges and write this file +# +# Expected flow: +# 1. Parent creates session with allowed_tool and restricted_tool, plus restricted-agent +# 2. restricted-agent only has allowed_tool in its Tools allowlist +# 3. User asks restricted-agent to invoke restricted_tool +# 4. CLI sends tool.call with child session ID for restricted_tool +# 5. SDK resolves child→parent, checks allowlist, returns "unsupported" error +# 6. restricted_tool handler is never invoked +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/subagent_tool/subagent_invokes_parent_custom_tool.yaml b/test/snapshots/subagent_tool/subagent_invokes_parent_custom_tool.yaml new file mode 100644 index 000000000..c544f9ac1 --- /dev/null +++ b/test/snapshots/subagent_tool/subagent_invokes_parent_custom_tool.yaml @@ -0,0 +1,19 @@ +# Placeholder snapshot for subagent custom tool E2E test. +# This snapshot needs to be captured from a real CLI session. +# +# To capture: +# 1. Ensure COPILOT_CLI_PATH is set or CLI is installed +# 2. Run: cd go && go test -tags integration -v ./internal/e2e -run TestSubagentCustomTools +# 3. The proxy will capture the exchanges and write this file +# +# Expected flow: +# 1. Parent creates session with save_result tool and helper-agent subagent +# 2. User sends message triggering helper-agent +# 3. CLI creates child session, emits subagent.started with RemoteSessionID +# 4. Child LLM invokes save_result tool +# 5. CLI sends tool.call with child session ID +# 6. SDK resolves child→parent, dispatches to save_result handler +# 7. Tool result returned, subagent completes +models: + - claude-sonnet-4.5 +conversations: []