From 10ceaff8447cb466311dd983e45c95bca44bc91e Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 8 Jul 2026 09:50:29 -0700 Subject: [PATCH 1/3] Propagate request handler agent metadata Thread agentId, parentAgentId, and interactionType from llmInference request-start frames into the public request handler contexts across SDK languages. Extend CAPI/BYOK and subagent request-handler tests to prove the metadata is observable, and fix .NET forwarding so GET/HEAD requests do not get an empty content body when forwarding headers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/CopilotRequestHandler.cs | 17 ++- dotnet/test/E2E/CopilotRequestE2EProvider.cs | 16 ++- .../E2E/CopilotRequestSessionIdE2ETests.cs | 18 ++- dotnet/test/E2E/SubagentHooksE2ETests.cs | 46 ++++++- go/copilot_request_handler.go | 39 ++++-- .../copilot_request_session_id_e2e_test.go | 36 ++++- go/internal/e2e/subagent_hooks_e2e_test.go | 69 ++++++++++ .../github/copilot/CopilotRequestContext.java | 60 +++++++-- .../github/copilot/LlmInferenceAdapter.java | 7 +- .../CopilotRequestSessionIdE2ETest.java | 10 ++ .../copilot/CopilotRequestTestSupport.java | 6 +- .../github/copilot/SubagentHooksE2ETest.java | 125 ++++++++++++++++++ nodejs/src/copilotRequestHandler.ts | 12 ++ .../copilot_request_session_id.e2e.test.ts | 18 ++- nodejs/test/e2e/subagent_hooks.e2e.test.ts | 62 ++++++++- python/copilot/copilot_request_handler.py | 18 +++ .../test_copilot_request_session_id_e2e.py | 20 ++- python/e2e/test_subagent_hooks_e2e.py | 50 +++++++ rust/src/copilot_request_handler.rs | 15 +++ rust/tests/e2e/copilot_request_handler.rs | 56 ++++++-- rust/tests/e2e/subagent_hooks.rs | 105 +++++++++++++-- 21 files changed, 747 insertions(+), 58 deletions(-) create mode 100644 java/src/test/java/com/github/copilot/SubagentHooksE2ETest.java diff --git a/dotnet/src/CopilotRequestHandler.cs b/dotnet/src/CopilotRequestHandler.cs index f26fc70e0a..1b6045280d 100644 --- a/dotnet/src/CopilotRequestHandler.cs +++ b/dotnet/src/CopilotRequestHandler.cs @@ -49,6 +49,9 @@ public CopilotRequestContext(CopilotRequestContext original) : this(original.RequestId, original.Url, original.Headers) { SessionId = original.SessionId; + AgentId = original.AgentId; + ParentAgentId = original.ParentAgentId; + InteractionType = original.InteractionType; Transport = original.Transport; CancellationToken = original.CancellationToken; WebSocketResponse = original.WebSocketResponse; @@ -67,6 +70,15 @@ internal CopilotRequestContext(string requestId, string url, IReadOnlyDictionary /// Runtime session id that triggered the request, if any. public string? SessionId { get; init; } + /// Stable per-agent-instance id for the agent trajectory that issued this request. + public string? AgentId { get; init; } + + /// Id of the parent agent when this request was issued by a subagent. + public string? ParentAgentId { get; init; } + + /// Runtime classification for the interaction that produced this request. + public string? InteractionType { get; init; } + /// Transport the runtime would otherwise use. public CopilotRequestTransport Transport { get; init; } @@ -524,7 +536,7 @@ private static async Task BuildHttpRequestAsync(LlmInference continue; } - if (!message.Headers.TryAddWithoutValidation(name, values)) + if (!message.Headers.TryAddWithoutValidation(name, values) && hasBody) { message.Content ??= new ByteArrayContent([]); message.Content.Headers.TryAddWithoutValidation(name, values); @@ -870,6 +882,9 @@ public Task HttpRequestStartAsync(LlmInferen exchange.Context = new CopilotRequestContext(request.RequestId, request.Url, ToReadOnlyHeaders(request.Headers)) { SessionId = request.SessionId, + AgentId = request.AgentId, + ParentAgentId = request.ParentAgentId, + InteractionType = request.InteractionType, Transport = transport, CancellationToken = exchange.Abort.Token, }; diff --git a/dotnet/test/E2E/CopilotRequestE2EProvider.cs b/dotnet/test/E2E/CopilotRequestE2EProvider.cs index bade5c6e5d..89826b4f84 100644 --- a/dotnet/test/E2E/CopilotRequestE2EProvider.cs +++ b/dotnet/test/E2E/CopilotRequestE2EProvider.cs @@ -56,7 +56,13 @@ protected override async Task SendRequestAsync(HttpRequestM #else : await request.Content.ReadAsStringAsync().ConfigureAwait(false); #endif - _records.Enqueue(new InterceptedRequest(url, ctx.SessionId, bodyText)); + _records.Enqueue(new InterceptedRequest( + url, + ctx.SessionId, + ctx.AgentId, + ctx.ParentAgentId, + ctx.InteractionType, + bodyText)); return IsInferenceUrl(url) ? BuildInferenceResponse(url, bodyText) @@ -188,4 +194,10 @@ internal static HttpResponseMessage BuildNonInferenceResponse(string url) } /// A single request the callback intercepted. -internal sealed record InterceptedRequest(string Url, string? SessionId, string Body); +internal sealed record InterceptedRequest( + string Url, + string? SessionId, + string? AgentId, + string? ParentAgentId, + string? InteractionType, + string Body); diff --git a/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs b/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs index e09c72c46e..08dd075643 100644 --- a/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs +++ b/dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs @@ -53,7 +53,11 @@ public async Task Threads_The_Session_Id_Into_A_Capi_Session_Inference_Request() var inference = provider.InferenceRequests; Assert.NotEmpty(inference); - Assert.All(inference, r => Assert.Equal(capiSessionId, r.SessionId)); + Assert.All(inference, r => + { + Assert.Equal(capiSessionId, r.SessionId); + AssertAgentMetadata(r); + }); // Validate the final assistant response arrived (guards against truncated captures) Assert.Contains("OK from the synthetic", content); @@ -96,9 +100,19 @@ public async Task Threads_The_Session_Id_Into_A_Byok_Session_Inference_Request() var inference = provider.InferenceRequests; Assert.NotEmpty(inference); - Assert.All(inference, r => Assert.Equal(byokSessionId, r.SessionId)); + Assert.All(inference, r => + { + Assert.Equal(byokSessionId, r.SessionId); + AssertAgentMetadata(r); + }); // Validate the final assistant response arrived (guards against truncated captures) Assert.Contains("OK from the synthetic", content); } + + private static void AssertAgentMetadata(InterceptedRequest request) + { + Assert.False(string.IsNullOrEmpty(request.AgentId)); + Assert.False(string.IsNullOrEmpty(request.InteractionType)); + } } diff --git a/dotnet/test/E2E/SubagentHooksE2ETests.cs b/dotnet/test/E2E/SubagentHooksE2ETests.cs index 4723c19b78..2e05bb0b63 100644 --- a/dotnet/test/E2E/SubagentHooksE2ETests.cs +++ b/dotnet/test/E2E/SubagentHooksE2ETests.cs @@ -3,12 +3,15 @@ *--------------------------------------------------------------------------------------------*/ using System.Collections.Concurrent; +using System.Net.Http; using GitHub.Copilot.Test.Harness; using Xunit; using Xunit.Abstractions; namespace GitHub.Copilot.Test.E2E; +#pragma warning disable GHCP001 // The LLM inference surface is intentionally experimental. + public class SubagentHooksE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "subagent_hooks", output) { @@ -16,11 +19,15 @@ public class SubagentHooksE2ETests(E2ETestFixture fixture, ITestOutputHelper out public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_Tool_Calls() { var hookLog = new ConcurrentBag<(string Kind, string ToolName, string SessionId)>(); + var requestHandler = new RecordingForwardingRequestHandler(); // Create a client with the session-based subagents feature flag var env = new Dictionary(Ctx.GetEnvironment()); env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true"; - var client = Ctx.CreateClient(environment: env); + var client = Ctx.CreateClient(new CopilotClientOptions + { + RequestHandler = requestHandler + }, environment: env); var session = await client.CreateSessionAsync(new SessionConfig { @@ -69,5 +76,42 @@ await session.SendAndWaitAsync( // input.SessionId distinguishes parent from sub-agent Assert.NotEqual(viewPre[0].SessionId, taskPre[0].SessionId); + AssertSubagentRequestMetadata(requestHandler.InferenceRequests); + } + + private static void AssertSubagentRequestMetadata(IReadOnlyCollection records) + { + Assert.NotEmpty(records); + var subagentRequest = records.FirstOrDefault(r => !string.IsNullOrEmpty(r.ParentAgentId)); + Assert.NotNull(subagentRequest); + Assert.False(string.IsNullOrEmpty(subagentRequest.AgentId), + "Sub-agent inference request should carry an agent id"); + Assert.False(string.IsNullOrEmpty(subagentRequest.InteractionType), + "Sub-agent inference request should carry an interaction type"); + Assert.NotEqual(subagentRequest.ParentAgentId, subagentRequest.AgentId); + } + + private sealed class RecordingForwardingRequestHandler : CopilotRequestHandler + { + private readonly ConcurrentBag _records = []; + + public IReadOnlyCollection InferenceRequests => + [.. _records.Where(r => RecordingRequestHandler.IsInferenceUrl(r.Url))]; + + protected override Task SendRequestAsync(HttpRequestMessage request, CopilotRequestContext ctx) + { + _records.Add(new RequestRecord( + request.RequestUri!.ToString(), + ctx.AgentId, + ctx.ParentAgentId, + ctx.InteractionType)); + return base.SendRequestAsync(request, ctx); + } } + + private sealed record RequestRecord( + string Url, + string? AgentId, + string? ParentAgentId, + string? InteractionType); } diff --git a/go/copilot_request_handler.go b/go/copilot_request_handler.go index c1ac52bc2e..ba8bb9b919 100644 --- a/go/copilot_request_handler.go +++ b/go/copilot_request_handler.go @@ -50,8 +50,11 @@ var sharedHTTPTransport = func() http.RoundTripper { // CopilotRequestContext is the per-request context handed to every // [CopilotRequestHandler] seam. type CopilotRequestContext struct { - RequestID string - SessionID string + RequestID string + SessionID string + AgentID string + ParentAgentID string + InteractionType string // Transport is "http" (covering plain HTTP and SSE) or "websocket". Transport string Method string @@ -144,12 +147,12 @@ type CopilotWebSocketHandler interface { // copilotContextKey is used to attach [CopilotRequestContext] to an // [http.Request] so custom [http.RoundTripper] implementations can access -// metadata (e.g. SessionID) without additional parameters. +// metadata (e.g. SessionID and AgentID) without additional parameters. type copilotContextKey struct{} // RequestContextFrom returns the [CopilotRequestContext] attached to an // http.Request by the adapter, or nil if not present. Call this from a custom -// [http.RoundTripper] to access metadata such as SessionID. +// [http.RoundTripper] to access metadata such as SessionID and AgentID. func RequestContextFrom(r *http.Request) *CopilotRequestContext { v, _ := r.Context().Value(copilotContextKey{}).(*CopilotRequestContext) return v @@ -194,7 +197,7 @@ func buildHTTPRequest(rctx *CopilotRequestContext) (*http.Request, error) { return nil, err } // Attach rctx so custom RoundTripper implementations can read metadata - // (e.g. SessionID) via [RequestContextFrom]. + // (e.g. SessionID and AgentID) via [RequestContextFrom]. httpReq = httpReq.WithContext(context.WithValue(httpReq.Context(), copilotContextKey{}, rctx)) for name, values := range rctx.Headers { if isForbiddenRequestHeader(name) { @@ -615,14 +618,17 @@ func (a *copilotRequestAdapter) HttpRequestStart(params *rpc.LlmInferenceHTTPReq } rctx := &CopilotRequestContext{ - RequestID: params.RequestID, - SessionID: sessionID, - Method: params.Method, - URL: params.URL, - Headers: headers, - Transport: transport, - body: bodyCh, - Context: ctx, + RequestID: params.RequestID, + SessionID: sessionID, + AgentID: stringOrEmpty(params.AgentID), + ParentAgentID: stringOrEmpty(params.ParentAgentID), + InteractionType: stringOrEmpty(params.InteractionType), + Method: params.Method, + URL: params.URL, + Headers: headers, + Transport: transport, + body: bodyCh, + Context: ctx, } sink := &responseSink{requestID: params.RequestID, adapter: a, exchange: exchange} go a.runHandler(rctx, sink, exchange) @@ -706,6 +712,13 @@ func (a *copilotRequestAdapter) removePending(requestID string) { a.mu.Unlock() } +func stringOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} + func decodeChunkData(data string, binary bool) ([]byte, error) { if binary { return base64.StdEncoding.DecodeString(data) diff --git a/go/internal/e2e/copilot_request_session_id_e2e_test.go b/go/internal/e2e/copilot_request_session_id_e2e_test.go index e88b91c971..e3569d3806 100644 --- a/go/internal/e2e/copilot_request_session_id_e2e_test.go +++ b/go/internal/e2e/copilot_request_session_id_e2e_test.go @@ -16,9 +16,12 @@ import ( ) type interceptedRequest struct { - url string - sessionID string - body string + url string + sessionID string + agentID string + parentAgentID string + interactionType string + body string } // recordingTransport intercepts every model-layer request, records its URL and @@ -32,8 +35,14 @@ type recordingTransport struct { func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) { rctx := copilot.RequestContextFrom(req) sessionID := "" + agentID := "" + parentAgentID := "" + interactionType := "" if rctx != nil { sessionID = rctx.SessionID + agentID = rctx.AgentID + parentAgentID = rctx.ParentAgentID + interactionType = rctx.InteractionType } bodyBytes := []byte(nil) if req.Body != nil { @@ -42,7 +51,14 @@ func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, erro bodyText := string(bodyBytes) rt.mu.Lock() - rt.records = append(rt.records, interceptedRequest{url: req.URL.String(), sessionID: sessionID, body: bodyText}) + rt.records = append(rt.records, interceptedRequest{ + url: req.URL.String(), + sessionID: sessionID, + agentID: agentID, + parentAgentID: parentAgentID, + interactionType: interactionType, + body: bodyText, + }) rt.mu.Unlock() if isInferenceURL(req.URL.String()) { @@ -63,6 +79,16 @@ func (rt *recordingTransport) inferenceRecords() []interceptedRequest { return out } +func assertAgentMetadata(t *testing.T, r interceptedRequest) { + t.Helper() + if r.agentID == "" { + t.Fatal("inference request must carry an agent id") + } + if r.interactionType == "" { + t.Fatal("inference request must carry an interaction type") + } +} + func TestCopilotRequestSessionID(t *testing.T) { ctx := testharness.NewTestContext(t) transport := &recordingTransport{} @@ -99,6 +125,7 @@ func TestCopilotRequestSessionID(t *testing.T) { if r.sessionID != capiSessionID { t.Fatalf("CAPI inference request must carry session id %q, got %q", capiSessionID, r.sessionID) } + assertAgentMetadata(t, r) } // Validate the final assistant response arrived (guards against truncated captures) @@ -140,6 +167,7 @@ func TestCopilotRequestSessionID(t *testing.T) { if r.sessionID != byokSessionID { t.Fatalf("BYOK inference request must carry session id %q, got %q", byokSessionID, r.sessionID) } + assertAgentMetadata(t, r) } if byokSessionID == capiSessionID { diff --git a/go/internal/e2e/subagent_hooks_e2e_test.go b/go/internal/e2e/subagent_hooks_e2e_test.go index c632b1e606..6a5000a2c4 100644 --- a/go/internal/e2e/subagent_hooks_e2e_test.go +++ b/go/internal/e2e/subagent_hooks_e2e_test.go @@ -1,6 +1,7 @@ package e2e import ( + "net/http" "os" "path/filepath" "sync" @@ -10,10 +11,77 @@ import ( "github.com/github/copilot-sdk/go/internal/e2e/testharness" ) +type subagentRequestRecord struct { + agentID string + parentAgentID string + interactionType string +} + +type recordingForwardingTransport struct { + inner http.RoundTripper + mu sync.Mutex + records []subagentRequestRecord +} + +func newRecordingForwardingTransport() *recordingForwardingTransport { + inner := http.DefaultTransport.(*http.Transport).Clone() + inner.DisableCompression = true + return &recordingForwardingTransport{inner: inner} +} + +func (rt *recordingForwardingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if isInferenceURL(req.URL.String()) { + rctx := copilot.RequestContextFrom(req) + record := subagentRequestRecord{} + if rctx != nil { + record.agentID = rctx.AgentID + record.parentAgentID = rctx.ParentAgentID + record.interactionType = rctx.InteractionType + } + rt.mu.Lock() + rt.records = append(rt.records, record) + rt.mu.Unlock() + } + return rt.inner.RoundTrip(req) +} + +func (rt *recordingForwardingTransport) inferenceRecords() []subagentRequestRecord { + rt.mu.Lock() + defer rt.mu.Unlock() + out := make([]subagentRequestRecord, len(rt.records)) + copy(out, rt.records) + return out +} + +func assertSubagentRequestMetadata(t *testing.T, records []subagentRequestRecord) { + t.Helper() + if len(records) == 0 { + t.Fatal("request handler should observe inference requests") + } + for _, r := range records { + if r.parentAgentID == "" { + continue + } + if r.agentID == "" { + t.Fatal("sub-agent inference request should carry an agent id") + } + if r.interactionType == "" { + t.Fatal("sub-agent inference request should carry an interaction type") + } + if r.parentAgentID == r.agentID { + t.Fatal("sub-agent inference request should have distinct parent and child agent ids") + } + return + } + t.Fatal("sub-agent inference request should carry a parent agent id") +} + func TestSubagentHooksE2E(t *testing.T) { ctx := testharness.NewTestContext(t) + transport := newRecordingForwardingTransport() client := ctx.NewClient(func(o *copilot.ClientOptions) { o.Env = append(o.Env, "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS=true") + o.RequestHandler = &copilot.CopilotRequestHandler{Transport: transport} }) t.Cleanup(func() { client.ForceStop() }) @@ -100,5 +168,6 @@ func TestSubagentHooksE2E(t *testing.T) { if viewPre[0].sessionID == taskPre.sessionID { t.Error("Sub-agent tool hooks should have a different sessionId than parent tool hooks") } + assertSubagentRequestMetadata(t, transport.inferenceRecords()) }) } diff --git a/java/src/main/java/com/github/copilot/CopilotRequestContext.java b/java/src/main/java/com/github/copilot/CopilotRequestContext.java index 0948a24c28..610fb56c6d 100644 --- a/java/src/main/java/com/github/copilot/CopilotRequestContext.java +++ b/java/src/main/java/com/github/copilot/CopilotRequestContext.java @@ -22,6 +22,12 @@ public final class CopilotRequestContext { private final String requestId; @Nullable private final String sessionId; + @Nullable + private final String agentId; + @Nullable + private final String parentAgentId; + @Nullable + private final String interactionType; private final CopilotRequestTransport transport; private final String url; private final Map> headers; @@ -29,20 +35,25 @@ public final class CopilotRequestContext { private LlmWebSocketResponseBridge webSocketResponse; - CopilotRequestContext(String requestId, @Nullable String sessionId, CopilotRequestTransport transport, String url, - Map> headers, CompletableFuture cancellation) { + CopilotRequestContext(String requestId, @Nullable String sessionId, @Nullable String agentId, + @Nullable String parentAgentId, @Nullable String interactionType, CopilotRequestTransport transport, + String url, Map> headers, CompletableFuture cancellation) { this.requestId = requestId; this.sessionId = sessionId; + this.agentId = agentId; + this.parentAgentId = parentAgentId; + this.interactionType = interactionType; this.transport = transport; this.url = url; this.headers = headers; this.cancellation = cancellation; } - private CopilotRequestContext(String requestId, @Nullable String sessionId, CopilotRequestTransport transport, + private CopilotRequestContext(String requestId, @Nullable String sessionId, @Nullable String agentId, + @Nullable String parentAgentId, @Nullable String interactionType, CopilotRequestTransport transport, String url, Map> headers, CompletableFuture cancellation, LlmWebSocketResponseBridge webSocketResponse) { - this(requestId, sessionId, transport, url, headers, cancellation); + this(requestId, sessionId, agentId, parentAgentId, interactionType, transport, url, headers, cancellation); this.webSocketResponse = webSocketResponse; } @@ -68,6 +79,39 @@ public String sessionId() { return sessionId; } + /** + * Gets the stable per-agent-instance id for the agent trajectory that issued + * this request, or {@code null} when no agent is in scope. + * + * @return the agent id, or {@code null} + */ + @Nullable + public String agentId() { + return agentId; + } + + /** + * Gets the id of the parent agent when this request was issued by a subagent, + * or {@code null} for root-agent and non-agent requests. + * + * @return the parent agent id, or {@code null} + */ + @Nullable + public String parentAgentId() { + return parentAgentId; + } + + /** + * Gets the runtime classification for the interaction that produced this + * request, or {@code null} when the runtime did not classify it. + * + * @return the interaction type, or {@code null} + */ + @Nullable + public String interactionType() { + return interactionType; + } + /** * Gets the transport the runtime would otherwise use. * @@ -103,8 +147,8 @@ public Map> headers() { * @return the copied context */ public CopilotRequestContext withUrl(String url) { - return new CopilotRequestContext(requestId, sessionId, transport, url, headers, cancellation, - webSocketResponse); + return new CopilotRequestContext(requestId, sessionId, agentId, parentAgentId, interactionType, transport, url, + headers, cancellation, webSocketResponse); } /** @@ -115,8 +159,8 @@ public CopilotRequestContext withUrl(String url) { * @return the copied context */ public CopilotRequestContext withHeaders(Map> headers) { - return new CopilotRequestContext(requestId, sessionId, transport, url, headers, cancellation, - webSocketResponse); + return new CopilotRequestContext(requestId, sessionId, agentId, parentAgentId, interactionType, transport, url, + headers, cancellation, webSocketResponse); } /** diff --git a/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java b/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java index 9087df6c1f..3e741a56bc 100644 --- a/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java +++ b/java/src/main/java/com/github/copilot/LlmInferenceAdapter.java @@ -65,6 +65,9 @@ private LlmInferenceExchange getOrCreateExchange(String requestId) { private void handleRequestStart(JsonRpcClient rpc, String rpcId, JsonNode params) { String requestId = params.get("requestId").asText(); String sessionId = textOrNull(params, "sessionId"); + String agentId = textOrNull(params, "agentId"); + String parentAgentId = textOrNull(params, "parentAgentId"); + String interactionType = textOrNull(params, "interactionType"); String method = textOrNull(params, "method"); String url = textOrNull(params, "url"); CopilotRequestTransport transport = CopilotRequestTransport.fromWire(textOrNull(params, "transport")); @@ -74,8 +77,8 @@ private void handleRequestStart(JsonRpcClient rpc, String rpcId, JsonNode params // body — rather than dropping those frames. LlmInferenceExchange exchange = getOrCreateExchange(requestId); exchange.setMethod(method); - exchange.setContext( - new CopilotRequestContext(requestId, sessionId, transport, url, headers, exchange.cancellation())); + exchange.setContext(new CopilotRequestContext(requestId, sessionId, agentId, parentAgentId, interactionType, + transport, url, headers, exchange.cancellation())); // Return from httpRequestStart immediately (after registering state) so the // runtime's RPC reply is not gated on the consumer's I/O. The actual handler diff --git a/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java b/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java index daf524945e..3025c64c39 100644 --- a/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java +++ b/java/src/test/java/com/github/copilot/CopilotRequestSessionIdE2ETest.java @@ -10,6 +10,7 @@ import static com.github.copilot.CopilotRequestTestSupport.setupCapiAuth; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -68,6 +69,7 @@ void threadsSessionIdForCapiAndByok() throws Exception { assertFalse(capiInference.isEmpty(), "Expected at least one intercepted inference request"); for (InterceptedRequest r : capiInference) { assertEquals(capiSessionId, r.sessionId(), "CAPI inference request must carry the session id"); + assertAgentMetadata(r); } assertTrue(assistantText(capiResult).contains("OK from the synthetic"), "Expected synthetic content in CAPI assistant reply, got " + assistantText(capiResult)); @@ -91,10 +93,18 @@ void threadsSessionIdForCapiAndByok() throws Exception { assertTrue(byokInference.size() > before, "Expected at least one intercepted BYOK inference request"); for (InterceptedRequest r : byokInference.subList(before, byokInference.size())) { assertEquals(byokSessionId, r.sessionId(), "BYOK inference request must carry the session id"); + assertAgentMetadata(r); } assertNotEquals(capiSessionId, byokSessionId, "Expected per-session ids to differ between turns"); assertTrue(assistantText(byokResult).contains("OK from the synthetic"), "Expected synthetic content in BYOK assistant reply, got " + assistantText(byokResult)); } } + + private static void assertAgentMetadata(InterceptedRequest request) { + assertNotNull(request.agentId(), "Inference request must carry an agent id"); + assertFalse(request.agentId().isEmpty(), "Inference request must carry an agent id"); + assertNotNull(request.interactionType(), "Inference request must carry an interaction type"); + assertFalse(request.interactionType().isEmpty(), "Inference request must carry an interaction type"); + } } diff --git a/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java b/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java index 7a2e7f2f0f..ecbf92068f 100644 --- a/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java +++ b/java/src/test/java/com/github/copilot/CopilotRequestTestSupport.java @@ -473,7 +473,8 @@ static String assistantText(AssistantMessageEvent event) { } /** A single request the handler intercepted. */ - record InterceptedRequest(String url, String sessionId, String body) { + record InterceptedRequest(String url, String sessionId, String agentId, String parentAgentId, + String interactionType, String body) { } /** @@ -509,7 +510,8 @@ protected HttpResponse sendRequest(HttpRequest request, CopilotRequ throws Exception { String url = request.uri().toString(); String body = requestBodyText(request); - records.add(new InterceptedRequest(url, ctx.sessionId(), body)); + records.add(new InterceptedRequest(url, ctx.sessionId(), ctx.agentId(), ctx.parentAgentId(), + ctx.interactionType(), body)); if (isInferenceUrl(url)) { return buildInferenceResponse(url, body, text); } diff --git a/java/src/test/java/com/github/copilot/SubagentHooksE2ETest.java b/java/src/test/java/com/github/copilot/SubagentHooksE2ETest.java new file mode 100644 index 0000000000..c2ad45ff24 --- /dev/null +++ b/java/src/test/java/com/github/copilot/SubagentHooksE2ETest.java @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.InputStream; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.MessageOptions; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.PostToolUseHookOutput; +import com.github.copilot.rpc.PreToolUseHookOutput; +import com.github.copilot.rpc.SessionConfig; +import com.github.copilot.rpc.SessionHooks; + +public class SubagentHooksE2ETest { + + private static final String SNAPSHOT_NAME = "should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls"; + + @Test + void shouldInvokePreToolUseAndPostToolUseHooksForSubAgentToolCalls() throws Exception { + try (E2ETestContext ctx = E2ETestContext.create()) { + ctx.configureForTest("subagent_hooks", SNAPSHOT_NAME); + + ConcurrentLinkedQueue hookLog = new ConcurrentLinkedQueue<>(); + RecordingForwardingRequestHandler requestHandler = new RecordingForwardingRequestHandler(); + HashMap env = new HashMap<>(ctx.getEnvironment()); + env.put("COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS", "true"); + + try (CopilotClient client = ctx + .createClient(new CopilotClientOptions().setEnvironment(env).setRequestHandler(requestHandler))) { + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnPreToolUse((input, invocation) -> { + hookLog.add(new HookEntry("pre", input.getToolName(), input.getSessionId())); + return CompletableFuture.completedFuture(PreToolUseHookOutput.allow()); + }).setOnPostToolUse((input, invocation) -> { + hookLog.add(new HookEntry("post", input.getToolName(), input.getSessionId())); + return CompletableFuture.completedFuture((PostToolUseHookOutput) null); + }))) + .get(); + try { + Files.writeString(ctx.getWorkDir().resolve("subagent-test.txt"), "Hello from subagent test!"); + session.sendAndWait(new MessageOptions() + .setPrompt("Use the task tool to spawn an explore agent that reads the file " + + "subagent-test.txt in the current directory and reports its contents. " + + "You must use the task tool.")) + .get(120, TimeUnit.SECONDS); + + HookEntry taskPre = hookLog.stream() + .filter(h -> h.kind().equals("pre") && h.toolName().equals("task")).findFirst() + .orElse(null); + assertNotNull(taskPre, "preToolUse should fire for the parent's 'task' tool call"); + + List viewPre = hookLog.stream() + .filter(h -> h.kind().equals("pre") && h.toolName().equals("view")).toList(); + List viewPost = hookLog.stream() + .filter(h -> h.kind().equals("post") && h.toolName().equals("view")).toList(); + assertFalse(viewPre.isEmpty(), "preToolUse should fire for the sub-agent's 'view' tool call"); + assertFalse(viewPost.isEmpty(), "postToolUse should fire for the sub-agent's 'view' tool call"); + assertNotEquals(taskPre.sessionId(), viewPre.get(0).sessionId(), + "Sub-agent tool hooks should have a different sessionId than parent tool hooks"); + assertSubagentRequestMetadata(requestHandler.inferenceRequests()); + } finally { + session.close(); + } + } + } + } + + private static void assertSubagentRequestMetadata(List records) { + assertFalse(records.isEmpty(), "request handler should observe inference requests"); + RequestRecord subagentRequest = records.stream() + .filter(r -> r.parentAgentId() != null && !r.parentAgentId().isEmpty()).findFirst().orElse(null); + assertNotNull(subagentRequest, "sub-agent inference request should carry a parentAgentId"); + assertFalse(subagentRequest.agentId() == null || subagentRequest.agentId().isEmpty(), + "sub-agent inference request should carry an agentId"); + assertFalse(subagentRequest.interactionType() == null || subagentRequest.interactionType().isEmpty(), + "sub-agent inference request should carry an interactionType"); + assertNotEquals(subagentRequest.parentAgentId(), subagentRequest.agentId()); + } + + private static boolean isInferenceUrl(String url) { + String u = url.toLowerCase(); + return u.endsWith("/chat/completions") || u.endsWith("/responses") || u.endsWith("/v1/messages") + || u.endsWith("/messages"); + } + + private record HookEntry(String kind, String toolName, String sessionId) { + } + + private record RequestRecord(String url, String agentId, String parentAgentId, String interactionType) { + } + + private static final class RecordingForwardingRequestHandler extends CopilotRequestHandler { + private final ConcurrentLinkedQueue records = new ConcurrentLinkedQueue<>(); + + List inferenceRequests() { + return records.stream().filter(r -> isInferenceUrl(r.url())).toList(); + } + + @Override + protected HttpResponse sendRequest(HttpRequest request, CopilotRequestContext ctx) + throws Exception { + records.add(new RequestRecord(request.uri().toString(), ctx.agentId(), ctx.parentAgentId(), + ctx.interactionType())); + return super.sendRequest(request, ctx); + } + } +} diff --git a/nodejs/src/copilotRequestHandler.ts b/nodejs/src/copilotRequestHandler.ts index a949cecfd9..ccfe6591c6 100644 --- a/nodejs/src/copilotRequestHandler.ts +++ b/nodejs/src/copilotRequestHandler.ts @@ -33,6 +33,9 @@ type InternalContext = CopilotRequestContext & { [kBridge]: CopilotWebSocketResp export interface CopilotRequestContext { readonly requestId: string; readonly sessionId?: string; + readonly agentId?: string; + readonly parentAgentId?: string; + readonly interactionType?: string; readonly transport: "http" | "websocket"; url: string; headers: LlmInferenceHeaders; @@ -249,6 +252,9 @@ export class CopilotRequestHandler { const ctx: InternalContext = { requestId: exchange.requestId, sessionId: exchange.sessionId, + agentId: exchange.agentId, + parentAgentId: exchange.parentAgentId, + interactionType: exchange.interactionType, transport: exchange.transport, url: exchange.url, headers: exchange.headers, @@ -456,6 +462,9 @@ interface BodyQueueItem { class CopilotRequestExchange { readonly requestId: string; sessionId?: string; + agentId?: string; + parentAgentId?: string; + interactionType?: string; method = "GET"; url = ""; headers: LlmInferenceHeaders = {}; @@ -478,6 +487,9 @@ class CopilotRequestExchange { /** Fill in the request context once the matching start frame arrives. */ setContext(params: LlmInferenceHttpRequestStartRequest): void { this.sessionId = params.sessionId; + this.agentId = params.agentId; + this.parentAgentId = params.parentAgentId; + this.interactionType = params.interactionType; this.method = params.method; this.url = params.url; this.headers = params.headers; diff --git a/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts b/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts index 3f01475aae..bd070c20ca 100644 --- a/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts +++ b/nodejs/test/e2e/copilot_request_session_id.e2e.test.ts @@ -11,6 +11,9 @@ const SYNTHETIC_TEXT = "OK from the synthetic stream."; interface InterceptedRequest { url: string; sessionId?: string; + agentId?: string; + parentAgentId?: string; + interactionType?: string; } function isInferenceUrl(url: string): boolean { @@ -43,7 +46,13 @@ class RecordingRequestHandler extends CopilotRequestHandler { ctx: CopilotRequestContext ): Promise { const url = request.url; - this.records.push({ url, sessionId: ctx.sessionId }); + this.records.push({ + url, + sessionId: ctx.sessionId, + agentId: ctx.agentId, + parentAgentId: ctx.parentAgentId, + interactionType: ctx.interactionType, + }); const bodyText = request.body ? await request.text() : ""; return isInferenceUrl(url) ? buildInferenceResponse(url, bodyText) @@ -105,6 +114,11 @@ function buildNonInferenceResponse(url: string): Response { return json("{}"); } +function expectAgentMetadata(r: InterceptedRequest): void { + expect(r.agentId).toBeTruthy(); + expect(r.interactionType).toBeTruthy(); +} + const RESPONSES_STREAM_EVENTS: string[] = [ `event: response.created\ndata: ${JSON.stringify({ type: "response.created", @@ -273,6 +287,7 @@ describe("CopilotRequestHandler threads the runtime session id (CAPI + BYOK)", a expect(r.sessionId, "CAPI inference request must carry the runtime session id").toBe( session.sessionId ); + expectAgentMetadata(r); } // Validate the final assistant response arrived (guards against truncated captures) @@ -313,6 +328,7 @@ describe("CopilotRequestHandler threads the runtime session id (CAPI + BYOK)", a expect(r.sessionId, "BYOK inference request must carry the runtime session id").toBe( byokSessionId ); + expectAgentMetadata(r); } // Session ids are per-session, so the two turns must differ — proves diff --git a/nodejs/test/e2e/subagent_hooks.e2e.test.ts b/nodejs/test/e2e/subagent_hooks.e2e.test.ts index ac0a694dca..dbc3ca673b 100644 --- a/nodejs/test/e2e/subagent_hooks.e2e.test.ts +++ b/nodejs/test/e2e/subagent_hooks.e2e.test.ts @@ -6,20 +6,79 @@ import { writeFile } from "fs/promises"; import { join } from "path"; import { describe, expect, it } from "vitest"; import type { + CopilotRequestContext, PreToolUseHookInput, PreToolUseHookOutput, PostToolUseHookInput, PostToolUseHookOutput, } from "../../src/index.js"; -import { approveAll } from "../../src/index.js"; +import { approveAll, CopilotRequestHandler } from "../../src/index.js"; import { createSdkTestContext, isCI } from "./harness/sdkTestContext.js"; +interface RequestRecord { + url: string; + agentId?: string; + parentAgentId?: string; + interactionType?: string; +} + +class RecordingRequestHandler extends CopilotRequestHandler { + readonly records: RequestRecord[] = []; + + protected override async sendRequest( + request: Request, + ctx: CopilotRequestContext + ): Promise { + this.records.push({ + url: request.url, + agentId: ctx.agentId, + parentAgentId: ctx.parentAgentId, + interactionType: ctx.interactionType, + }); + return super.sendRequest(request, ctx); + } +} + +function isInferenceUrl(url: string): boolean { + const u = url.toLowerCase(); + return ( + u.endsWith("/chat/completions") || + u.endsWith("/responses") || + u.endsWith("/v1/messages") || + u.endsWith("/messages") + ); +} + +function expectSubagentRequestMetadata(records: RequestRecord[]): void { + const inference = records.filter((r) => isInferenceUrl(r.url)); + expect(inference.length, "request handler should observe inference requests").toBeGreaterThan( + 0 + ); + + const subagentRequest = inference.find((r) => r.parentAgentId); + expect( + subagentRequest, + "sub-agent inference request should carry a parentAgentId" + ).toBeDefined(); + expect( + subagentRequest!.agentId, + "sub-agent inference request should carry an agentId" + ).toBeTruthy(); + expect( + subagentRequest!.interactionType, + "sub-agent inference request should carry an interactionType" + ).toBeTruthy(); + expect(subagentRequest!.parentAgentId).not.toBe(subagentRequest!.agentId); +} + describe("Subagent hooks", async () => { // For snapshot recording (non-CI), use RECORD_GH_TOKEN if available const recordToken = !isCI ? process.env.RECORD_GH_TOKEN : undefined; + const requestHandler = new RecordingRequestHandler(); const { copilotClient: client, workDir } = await createSdkTestContext({ copilotClientOptions: { ...(recordToken ? { gitHubToken: recordToken } : {}), + requestHandler, env: { COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS: "true" }, }, }); @@ -75,6 +134,7 @@ describe("Subagent hooks", async () => { // input.sessionId distinguishes parent from sub-agent: parent tools and // sub-agent tools carry different sessionIds expect(viewPre[0].sessionId).not.toBe(taskPre!.sessionId); + expectSubagentRequestMetadata(requestHandler.records); await session.disconnect(); }, 120_000); diff --git a/python/copilot/copilot_request_handler.py b/python/copilot/copilot_request_handler.py index 54e71027c9..e6465b7bbc 100644 --- a/python/copilot/copilot_request_handler.py +++ b/python/copilot/copilot_request_handler.py @@ -96,6 +96,15 @@ class CopilotRequestContext: """Id of the runtime session that triggered this request, when in scope. Absent for out-of-session requests (e.g. the startup model catalog).""" + agent_id: str | None = None + """Stable per-agent-instance id for the agent trajectory that issued this request.""" + + parent_agent_id: str | None = None + """Id of the parent agent when this request was issued by a subagent.""" + + interaction_type: str | None = None + """Runtime classification for the interaction that produced this request.""" + _bridge: _CopilotWebSocketResponseBridge | None = field(default=None, repr=False) @@ -253,6 +262,9 @@ async def _dispatch(self, exchange: _CopilotRequestExchange) -> None: ctx = CopilotRequestContext( request_id=exchange.request_id, session_id=exchange.session_id, + agent_id=exchange.agent_id, + parent_agent_id=exchange.parent_agent_id, + interaction_type=exchange.interaction_type, transport=exchange.transport, url=exchange.url, headers=exchange.headers, @@ -382,6 +394,9 @@ def __init__( ) -> None: self.request_id = request_id self.session_id: str | None = None + self.agent_id: str | None = None + self.parent_agent_id: str | None = None + self.interaction_type: str | None = None self.method: str = "GET" self.url: str = "" self.headers: dict[str, list[str]] = {} @@ -397,6 +412,9 @@ def __init__( def set_context(self, params: LlmInferenceHTTPRequestStartRequest) -> None: """Fill in the request context once the matching start frame arrives.""" self.session_id = params.session_id + self.agent_id = params.agent_id + self.parent_agent_id = params.parent_agent_id + self.interaction_type = params.interaction_type self.method = params.method self.url = params.url self.headers = params.headers diff --git a/python/e2e/test_copilot_request_session_id_e2e.py b/python/e2e/test_copilot_request_session_id_e2e.py index e40af13a1c..81624d73d0 100644 --- a/python/e2e/test_copilot_request_session_id_e2e.py +++ b/python/e2e/test_copilot_request_session_id_e2e.py @@ -36,6 +36,9 @@ class _InterceptedRequest: url: str session_id: str | None + agent_id: str | None + parent_agent_id: str | None + interaction_type: str | None class _SessionIdHandler(CopilotRequestHandler): @@ -46,7 +49,15 @@ async def send_request( self, request: httpx.Request, ctx: CopilotRequestContext ) -> httpx.Response: url = str(request.url) - self.records.append(_InterceptedRequest(url=url, session_id=ctx.session_id)) + self.records.append( + _InterceptedRequest( + url=url, + session_id=ctx.session_id, + agent_id=ctx.agent_id, + parent_agent_id=ctx.parent_agent_id, + interaction_type=ctx.interaction_type, + ) + ) if is_inference_url(url): return build_inference_response(request) # Force /responses transport so the inference URL is predictable. @@ -56,6 +67,11 @@ async def send_request( session_id_client = isolated_client_fixture(_SessionIdHandler) +def _assert_agent_metadata(record: _InterceptedRequest) -> None: + assert record.agent_id + assert record.interaction_type + + class TestCopilotRequestSessionId: capi_session_id: str | None = None @@ -78,6 +94,7 @@ async def test_threads_session_id_into_capi_session(self, session_id_client): assert r.session_id == session.session_id, ( "CAPI inference request must carry the runtime session id" ) + _assert_agent_metadata(r) # Validate the final assistant response arrived (guards against truncated captures) assert "OK from the synthetic" in text @@ -112,6 +129,7 @@ async def test_threads_session_id_into_byok_session(self, session_id_client): assert r.session_id == byok_session_id, ( "BYOK inference request must carry the runtime session id" ) + _assert_agent_metadata(r) # Session ids are per-session, so the two turns must differ. assert byok_session_id != TestCopilotRequestSessionId.capi_session_id diff --git a/python/e2e/test_subagent_hooks_e2e.py b/python/e2e/test_subagent_hooks_e2e.py index 1ca2a54c12..da70265a04 100644 --- a/python/e2e/test_subagent_hooks_e2e.py +++ b/python/e2e/test_subagent_hooks_e2e.py @@ -3,10 +3,14 @@ fire for tool calls made by sub-agents spawned via the task tool. """ +from __future__ import annotations + import os +import httpx import pytest +from copilot import CopilotRequestContext, CopilotRequestHandler from copilot.client import CopilotClient, RuntimeConnection from copilot.session import PermissionHandler @@ -16,12 +20,56 @@ pytestmark = pytest.mark.asyncio(loop_scope="module") +class _RecordingRequestHandler(CopilotRequestHandler): + def __init__(self) -> None: + self.records: list[dict[str, str | None]] = [] + + async def send_request( + self, request: httpx.Request, ctx: CopilotRequestContext + ) -> httpx.Response: + self.records.append( + { + "url": str(request.url), + "agent_id": ctx.agent_id, + "parent_agent_id": ctx.parent_agent_id, + "interaction_type": ctx.interaction_type, + } + ) + return await super().send_request(request, ctx) + + +def _is_inference_url(url: str) -> bool: + u = url.lower() + return ( + u.endswith("/chat/completions") + or u.endswith("/responses") + or u.endswith("/v1/messages") + or u.endswith("/messages") + ) + + +def _assert_subagent_request_metadata(records: list[dict[str, str | None]]) -> None: + inference = [r for r in records if _is_inference_url(r["url"] or "")] + assert len(inference) > 0, "request handler should observe inference requests" + + subagent_request = next((r for r in inference if r["parent_agent_id"]), None) + assert subagent_request is not None, ( + "sub-agent inference request should carry a parent_agent_id" + ) + assert subagent_request["agent_id"], "sub-agent inference request should carry an agent_id" + assert subagent_request["interaction_type"], ( + "sub-agent inference request should carry an interaction_type" + ) + assert subagent_request["parent_agent_id"] != subagent_request["agent_id"] + + class TestSubagentHooks: async def test_should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls( self, ctx: E2ETestContext ): """Test that preToolUse/postToolUse hooks fire for sub-agent tool calls""" hook_log = [] + request_handler = _RecordingRequestHandler() async def on_pre_tool_use(input_data, invocation): hook_log.append( @@ -54,6 +102,7 @@ async def on_post_tool_use(input_data, invocation): working_directory=ctx.work_dir, env=env, github_token=github_token, + request_handler=request_handler, ) session = await client.create_session( @@ -87,6 +136,7 @@ async def on_post_tool_use(input_data, invocation): assert view_pre[0]["sessionId"] != task_pre[0]["sessionId"], ( "Sub-agent tool hooks should have a different sessionId than parent tool hooks" ) + _assert_subagent_request_metadata(request_handler.records) await session.disconnect() await client.stop() diff --git a/rust/src/copilot_request_handler.rs b/rust/src/copilot_request_handler.rs index b686b6eada..961ae3876e 100644 --- a/rust/src/copilot_request_handler.rs +++ b/rust/src/copilot_request_handler.rs @@ -140,6 +140,12 @@ pub struct CopilotRequestContext { /// Id of the runtime session that triggered this request, or `None` when it /// was issued outside any session (for example the startup model catalog). pub session_id: Option, + /// Stable per-agent-instance id for the agent trajectory that issued this request. + pub agent_id: Option, + /// Id of the parent agent when this request was issued by a subagent. + pub parent_agent_id: Option, + /// Runtime classification for the interaction that produced this request. + pub interaction_type: Option, /// Transport the runtime would otherwise use. pub transport: CopilotRequestTransport, /// Absolute request URL. @@ -594,6 +600,9 @@ struct ResponseState { #[derive(Default)] struct RequestMeta { session_id: Option, + agent_id: Option, + parent_agent_id: Option, + interaction_type: Option, method: String, url: String, headers: HeaderMap, @@ -630,6 +639,9 @@ impl CopilotRequestExchange { fn set_context(&self, params: LlmInferenceHttpRequestStartRequest) { let _ = self.meta.set(RequestMeta { session_id: params.session_id.map(SessionId::into_inner), + agent_id: params.agent_id, + parent_agent_id: params.parent_agent_id, + interaction_type: params.interaction_type, method: params.method, url: params.url, headers: headers_from_wire(¶ms.headers), @@ -649,6 +661,9 @@ impl CopilotRequestExchange { CopilotRequestContext { request_id: self.request_id.clone(), session_id: meta.session_id.clone(), + agent_id: meta.agent_id.clone(), + parent_agent_id: meta.parent_agent_id.clone(), + interaction_type: meta.interaction_type.clone(), transport: meta.transport, url: meta.url.clone(), headers: meta.headers.clone(), diff --git a/rust/tests/e2e/copilot_request_handler.rs b/rust/tests/e2e/copilot_request_handler.rs index 6ca99393bf..2dd1411734 100644 --- a/rust/tests/e2e/copilot_request_handler.rs +++ b/rust/tests/e2e/copilot_request_handler.rs @@ -572,16 +572,25 @@ async fn services_http_and_websocket_via_handler() { #[derive(Default)] struct RecordingHandler { - records: std::sync::Mutex)>>, + records: std::sync::Mutex>, +} + +#[derive(Clone)] +struct InterceptedRequest { + url: String, + session_id: Option, + agent_id: Option, + parent_agent_id: Option, + interaction_type: Option, } impl RecordingHandler { - fn inference_records(&self) -> Vec<(String, Option)> { + fn inference_records(&self) -> Vec { self.records .lock() .unwrap() .iter() - .filter(|(url, _)| is_inference_url(url)) + .filter(|record| is_inference_url(&record.url)) .cloned() .collect() } @@ -594,10 +603,13 @@ impl CopilotRequestHandler for RecordingHandler { request: CopilotHttpRequest, ctx: &CopilotRequestContext, ) -> Result { - self.records - .lock() - .unwrap() - .push((request.url.clone(), ctx.session_id.clone())); + self.records.lock().unwrap().push(InterceptedRequest { + url: request.url.clone(), + session_id: ctx.session_id.clone(), + agent_id: ctx.agent_id.clone(), + parent_agent_id: ctx.parent_agent_id.clone(), + interaction_type: ctx.interaction_type.clone(), + }); if is_inference_url(&request.url) { Ok(synth_inference_response( &request.url, @@ -632,12 +644,13 @@ async fn threads_session_id_into_inference() { !inference.is_empty(), "expected at least one intercepted inference request" ); - for (_, session_id) in &inference { + for record in &inference { assert_eq!( - session_id.as_deref(), + record.session_id.as_deref(), Some(capi_session_id.as_str()), "CAPI inference request must carry the session id" ); + assert_agent_metadata(record); } assert!( assistant_text(&result).contains("OK from the synthetic"), @@ -671,12 +684,13 @@ async fn threads_session_id_into_inference() { inference.len() > before, "expected at least one intercepted BYOK inference request" ); - for (_, session_id) in &inference[before..] { + for record in &inference[before..] { assert_eq!( - session_id.as_deref(), + record.session_id.as_deref(), Some(byok_session_id.as_str()), "BYOK inference request must carry the session id" ); + assert_agent_metadata(record); } assert_ne!( byok_session_id, capi_session_id, @@ -694,6 +708,26 @@ async fn threads_session_id_into_inference() { .await; } +fn assert_agent_metadata(record: &InterceptedRequest) { + assert!( + record.agent_id.as_deref().is_some_and(|id| !id.is_empty()), + "inference request must carry an agent id" + ); + if let Some(parent_agent_id) = record.parent_agent_id.as_deref() { + assert!( + !parent_agent_id.is_empty(), + "parent agent id must be non-empty when present" + ); + } + assert!( + record + .interaction_type + .as_deref() + .is_some_and(|kind| !kind.is_empty()), + "inference request must carry an interaction type" + ); +} + // --------------------------------------------------------------------------- // Scenario 3a: errors — a handler that returns `Err` on an inference request // surfaces a transport error rather than hanging the turn. diff --git a/rust/tests/e2e/subagent_hooks.rs b/rust/tests/e2e/subagent_hooks.rs index 99529c433b..8a21169c46 100644 --- a/rust/tests/e2e/subagent_hooks.rs +++ b/rust/tests/e2e/subagent_hooks.rs @@ -5,6 +5,10 @@ use github_copilot_sdk::hooks::{ HookContext, PostToolUseInput, PostToolUseOutput, PreToolUseInput, PreToolUseOutput, SessionHooks, }; +use github_copilot_sdk::{ + CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, CopilotRequestError, + CopilotRequestHandler, forward_http, +}; use parking_lot::Mutex; use super::support::with_e2e_context; @@ -24,16 +28,14 @@ async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls .expect("write test file"); let hook_log = Arc::new(Mutex::new(Vec::::new())); + let request_log = Arc::new(RecordingRequestHandler::default()); - let mut opts = ctx.client_options(); - opts.env.push(( - "COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS".into(), - "true".into(), - )); - - let client = github_copilot_sdk::Client::start(opts) - .await - .expect("start client"); + let client = ctx + .start_llm_client( + Arc::clone(&request_log), + &[("COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS", "true")], + ) + .await; let session = client .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( @@ -88,6 +90,7 @@ async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls task_pre.unwrap().session_id, "Sub-agent tool hooks should have a different sessionId than parent tool hooks" ); + assert_subagent_request_metadata(&request_log.inference_records()); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); @@ -104,6 +107,90 @@ struct HookEntry { session_id: String, } +#[derive(Clone, Debug)] +struct RequestEntry { + url: String, + agent_id: Option, + parent_agent_id: Option, + interaction_type: Option, +} + +#[derive(Default)] +struct RecordingRequestHandler { + log: Mutex>, +} + +impl RecordingRequestHandler { + fn inference_records(&self) -> Vec { + self.log + .lock() + .iter() + .filter(|entry| is_inference_url(&entry.url)) + .cloned() + .collect() + } +} + +#[async_trait] +impl CopilotRequestHandler for RecordingRequestHandler { + async fn send_request( + &self, + request: CopilotHttpRequest, + ctx: &CopilotRequestContext, + ) -> Result { + self.log.lock().push(RequestEntry { + url: request.url.clone(), + agent_id: ctx.agent_id.clone(), + parent_agent_id: ctx.parent_agent_id.clone(), + interaction_type: ctx.interaction_type.clone(), + }); + forward_http(request).await + } +} + +fn is_inference_url(url: &str) -> bool { + let url = url.to_lowercase(); + url.ends_with("/chat/completions") + || url.ends_with("/responses") + || url.ends_with("/v1/messages") + || url.ends_with("/messages") +} + +fn assert_subagent_request_metadata(records: &[RequestEntry]) { + assert!( + !records.is_empty(), + "request handler should observe inference requests" + ); + let subagent_request = records + .iter() + .find(|entry| { + entry + .parent_agent_id + .as_deref() + .is_some_and(|id| !id.is_empty()) + }) + .expect("sub-agent inference request should carry a parentAgentId"); + assert!( + subagent_request + .agent_id + .as_deref() + .is_some_and(|id| !id.is_empty()), + "sub-agent inference request should carry an agentId" + ); + assert!( + subagent_request + .interaction_type + .as_deref() + .is_some_and(|kind| !kind.is_empty()), + "sub-agent inference request should carry an interactionType" + ); + assert_ne!( + subagent_request.parent_agent_id.as_deref(), + subagent_request.agent_id.as_deref(), + "sub-agent inference request should have distinct parent and child agent ids" + ); +} + struct RecordingHooks { log: Arc>>, } From b7620be9624af523e14eaf67148fc545b2e3ea80 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 9 Jul 2026 17:47:15 -0700 Subject: [PATCH 2/3] Run subagent request handler test over stdio Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/test/E2E/SubagentHooksE2ETests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/test/E2E/SubagentHooksE2ETests.cs b/dotnet/test/E2E/SubagentHooksE2ETests.cs index 2e05bb0b63..a718c56c1a 100644 --- a/dotnet/test/E2E/SubagentHooksE2ETests.cs +++ b/dotnet/test/E2E/SubagentHooksE2ETests.cs @@ -26,6 +26,7 @@ public async Task Should_Invoke_PreToolUse_And_PostToolUse_Hooks_For_Sub_Agent_T env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true"; var client = Ctx.CreateClient(new CopilotClientOptions { + Connection = RuntimeConnection.ForStdio(), RequestHandler = requestHandler }, environment: env); From b453df46429132ef3f2865e4d6bb7b90767bd26a Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 9 Jul 2026 18:09:15 -0700 Subject: [PATCH 3/3] Limit bodyless header workaround to netstandard Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/CopilotRequestHandler.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dotnet/src/CopilotRequestHandler.cs b/dotnet/src/CopilotRequestHandler.cs index 1b6045280d..514d77da6f 100644 --- a/dotnet/src/CopilotRequestHandler.cs +++ b/dotnet/src/CopilotRequestHandler.cs @@ -536,8 +536,14 @@ private static async Task BuildHttpRequestAsync(LlmInference continue; } - if (!message.Headers.TryAddWithoutValidation(name, values) && hasBody) + if (!message.Headers.TryAddWithoutValidation(name, values)) { +#if NETSTANDARD2_0 + if (!hasBody) + { + continue; + } +#endif message.Content ??= new ByteArrayContent([]); message.Content.Headers.TryAddWithoutValidation(name, values); }