Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions dotnet/src/CopilotRequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -67,6 +70,15 @@ internal CopilotRequestContext(string requestId, string url, IReadOnlyDictionary
/// <summary>Runtime session id that triggered the request, if any.</summary>
public string? SessionId { get; init; }

/// <summary>Stable per-agent-instance id for the agent trajectory that issued this request.</summary>
public string? AgentId { get; init; }

/// <summary>Id of the parent agent when this request was issued by a subagent.</summary>
public string? ParentAgentId { get; init; }

/// <summary>Runtime classification for the interaction that produced this request.</summary>
public string? InteractionType { get; init; }

/// <summary>Transport the runtime would otherwise use.</summary>
public CopilotRequestTransport Transport { get; init; }

Expand Down Expand Up @@ -526,6 +538,12 @@ private static async Task<HttpRequestMessage> BuildHttpRequestAsync(LlmInference

if (!message.Headers.TryAddWithoutValidation(name, values))
{
#if NETSTANDARD2_0
if (!hasBody)
{
continue;
}
#endif
message.Content ??= new ByteArrayContent([]);
message.Content.Headers.TryAddWithoutValidation(name, values);
}
Expand Down Expand Up @@ -870,6 +888,9 @@ public Task<LlmInferenceHttpRequestStartResult> 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,
};
Expand Down
16 changes: 14 additions & 2 deletions dotnet/test/E2E/CopilotRequestE2EProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ protected override async Task<HttpResponseMessage> 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)
Expand Down Expand Up @@ -188,4 +194,10 @@ internal static HttpResponseMessage BuildNonInferenceResponse(string url)
}

/// <summary>A single request the callback intercepted.</summary>
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);
18 changes: 16 additions & 2 deletions dotnet/test/E2E/CopilotRequestSessionIdE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
}
}
47 changes: 46 additions & 1 deletion dotnet/test/E2E/SubagentHooksE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,32 @@
*--------------------------------------------------------------------------------------------*/

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)
{
[Fact]
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<string, string>(Ctx.GetEnvironment());
env["COPILOT_EXP_COPILOT_CLI_SESSION_BASED_SUBAGENTS"] = "true";
var client = Ctx.CreateClient(environment: env);
var client = Ctx.CreateClient(new CopilotClientOptions
{
Connection = RuntimeConnection.ForStdio(),
RequestHandler = requestHandler
}, environment: env);

var session = await client.CreateSessionAsync(new SessionConfig
{
Expand Down Expand Up @@ -69,5 +77,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<RequestRecord> 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<RequestRecord> _records = [];

public IReadOnlyCollection<RequestRecord> InferenceRequests =>
[.. _records.Where(r => RecordingRequestHandler.IsInferenceUrl(r.Url))];

protected override Task<HttpResponseMessage> 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);
}
39 changes: 26 additions & 13 deletions go/copilot_request_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 32 additions & 4 deletions go/internal/e2e/copilot_request_session_id_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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()) {
Expand All @@ -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{}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading