Skip to content

Commit 23d9a45

Browse files
ellismgCopilot
andauthored
Expose exp_assignments injection on session create/resume across all SDKs (#1750)
* Add exp_assignments injection to session create/resume config Expose ExP assignment ("flight") data on the SDK's session-open and session-resume paths so an out-of-process integrator can inject the same CopilotExpAssignmentResponse payload the CLI fetches itself. The runtime already accepts expAssignments on the wire, but the hand-written SessionCreateWire / SessionResumeWire structs (and their public configs) did not carry it. - SessionConfig / ResumeSessionConfig: add doc-hidden exp_assignments field (serde_json::Value) plus a doc-hidden with_exp_assignments builder - SessionCreateWire / SessionResumeWire: add exp_assignments, serialized as camelCase expAssignments and omitted when None - Forward the field through both into_wire paths - Unit tests asserting expAssignments is emitted on create and resume and omitted when unset Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Expose exp_assignments injection across Node, Python, Go, .NET, Java SDKs Mirror the Rust SDK change in the remaining five SDKs so out-of-process integrators can inject ExP ("flight") assignment data into session create and resume. Adds an internal/trusted-integrator config field that forwards to the wire key `expAssignments` (omitted when unset), in the opaque JSON shape of `CopilotExpAssignmentResponse`: - Node: `expAssignments?: Record<string, unknown>` on `SessionConfigBase` (`@internal`), forwarded in the inline session.create/session.resume payloads in client.ts. - Python: `exp_assignments: dict[str, Any] | None = None` kwarg on `create_session`/`resume_session`, mapped to `payload["expAssignments"]`. - Go: `ExpAssignments any` on `SessionConfig`/`ResumeSessionConfig` (documented Internal:), forwarded into the create/resume wire structs with `json:"expAssignments,omitempty"`. - .NET: `JsonElement? ExpAssignments` on `SessionConfigBase` (`[EditorBrowsable(Never)]`), wired through the internal CreateSessionRequest/ResumeSessionRequest records. - Java: `JsonNode expAssignments` field + fluent setter/getter on SessionConfig/ResumeSessionConfig, mapped through CreateSessionRequest/ResumeSessionRequest in SessionRequestBuilder. Each language gains create+resume serialization tests asserting the field serializes to `expAssignments` when set and is omitted when unset. Part of github/github-app epic #7452; mirrors the runtime contract added in github/copilot-agent-runtime#9955. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix .NET clone dropping ExpAssignments + add clone regression coverage The SessionConfigBase copy constructor (used by SessionConfig.Clone() and ResumeSessionConfig.Clone()) did not copy the newly added ExpAssignments property, so cloning a config silently dropped it and it would not be forwarded on create/resume. Copy it alongside the other base properties. Cross-language audit of every config copy/clone path: - .NET: had the gap (fixed here). - Java: SessionConfig/ResumeSessionConfig clone() already copy expAssignments (safe). - Rust: SessionConfig/ResumeSessionConfig derive Clone, no manual impl (safe). - Node: createSession/resumeSession normalize via { ...defaults, ...config } spread, which preserves all own properties (safe). - Go: create/resume requests assign req.ExpAssignments = config.ExpAssignments directly from the config pointer (safe). - Python: exp_assignments is an explicit kwarg mapped straight into the payload, no intermediate dict copy (safe). Add clone regression tests for .NET, Java, and Rust (the three with explicit Clone()/clone() methods) that set expAssignments, clone, and assert it survives and is forwarded on the resulting create/resume request. The .NET tests fail against the pre-fix copy constructor. Node's existing forward test already exercises the spread-normalization copy path. Switch the two new Java expAssignments tests off the deprecated buildCreateRequest(SessionConfig) overload to the current buildCreateRequest(SessionConfig, String) form to avoid new CodeQL debt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 079c558 commit 23d9a45

19 files changed

Lines changed: 685 additions & 6 deletions

File tree

dotnet/src/Client.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,7 +1003,8 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
10031003
ExtensionInfo: config.ExtensionInfo,
10041004
Providers: config.Providers,
10051005
Models: config.Models,
1006-
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence);
1006+
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
1007+
ExpAssignments: config.ExpAssignments);
10071008

10081009
var rpcTimestamp = Stopwatch.GetTimestamp();
10091010

@@ -1204,7 +1205,8 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
12041205
OpenCanvases: config.OpenCanvases,
12051206
Providers: config.Providers,
12061207
Models: config.Models,
1207-
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence);
1208+
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
1209+
ExpAssignments: config.ExpAssignments);
12081210

12091211
var rpcTimestamp = Stopwatch.GetTimestamp();
12101212
var response = await InvokeRpcAsync<ResumeSessionResponse>(
@@ -2444,7 +2446,8 @@ internal record CreateSessionRequest(
24442446
ExtensionInfo? ExtensionInfo = null,
24452447
IList<NamedProviderConfig>? Providers = null,
24462448
IList<ProviderModelConfig>? Models = null,
2447-
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null);
2449+
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null,
2450+
[property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null);
24482451
#pragma warning restore GHCP001
24492452

24502453
internal record ToolDefinition(
@@ -2539,7 +2542,8 @@ internal record ResumeSessionRequest(
25392542
IList<OpenCanvasInstance>? OpenCanvases = null,
25402543
IList<NamedProviderConfig>? Providers = null,
25412544
IList<ProviderModelConfig>? Models = null,
2542-
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null);
2545+
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null,
2546+
[property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null);
25432547
#pragma warning restore GHCP001
25442548

25452549
internal record ResumeSessionResponse(

dotnet/src/Types.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2688,6 +2688,7 @@ protected SessionConfigBase(SessionConfigBase? other)
26882688
CreateSessionFsProvider = other.CreateSessionFsProvider;
26892689
GitHubToken = other.GitHubToken;
26902690
RemoteSession = other.RemoteSession;
2691+
ExpAssignments = other.ExpAssignments;
26912692
#pragma warning disable GHCP001
26922693
Canvases = other.Canvases is not null ? [.. other.Canvases] : null;
26932694
RequestCanvasRenderer = other.RequestCanvasRenderer;
@@ -3064,6 +3065,23 @@ protected SessionConfigBase(SessionConfigBase? other)
30643065
/// </summary>
30653066
public RemoteSessionMode? RemoteSession { get; set; }
30663067

3068+
/// <summary>
3069+
/// ExP assignment ("flight") data injected by a trusted integrator, in the
3070+
/// same JSON shape the Copilot CLI fetches from the experimentation service
3071+
/// (<c>CopilotExpAssignmentResponse</c>). When provided, the runtime feeds it
3072+
/// into the same feature-flag path as CLI-fetched assignments and stamps it
3073+
/// onto telemetry and the CAPI request header. When unset, the session does
3074+
/// not block on ExP. Intended for out-of-process integrators that fetch ExP
3075+
/// data themselves; malformed payloads are dropped by the runtime (fail-open).
3076+
/// Serialized on the wire as <c>expAssignments</c>.
3077+
/// </summary>
3078+
/// <remarks>
3079+
/// This is an internal/trusted-integrator option and is hidden from editor
3080+
/// completion. It is not part of the broadly advertised public surface.
3081+
/// </remarks>
3082+
[EditorBrowsable(EditorBrowsableState.Never)]
3083+
public JsonElement? ExpAssignments { get; set; }
3084+
30673085
#pragma warning disable GHCP001
30683086
/// <summary>
30693087
/// Canvas declarations advertised by this connection. The runtime forwards

dotnet/test/Unit/SerializationTests.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,97 @@ public void SessionRequests_OmitMemory_WhenUnset()
446446
Assert.False(resumeDocument.RootElement.TryGetProperty("memory", out _));
447447
}
448448

449+
[Fact]
450+
public void SessionRequests_CanSerializeExpAssignments_WithSdkOptions()
451+
{
452+
var options = GetSerializerOptions();
453+
454+
using var createAssignments = JsonDocument.Parse("""{"Configs":[{"Id":"exp-create"}]}""");
455+
var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest");
456+
var createRequest = CreateInternalRequest(
457+
createRequestType,
458+
("SessionId", "session-id"),
459+
("ExpAssignments", createAssignments.RootElement.Clone()));
460+
461+
var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options);
462+
using var createDocument = JsonDocument.Parse(createJson);
463+
var createRoot = createDocument.RootElement;
464+
Assert.Equal("exp-create", createRoot.GetProperty("expAssignments").GetProperty("Configs")[0].GetProperty("Id").GetString());
465+
466+
using var resumeAssignments = JsonDocument.Parse("""{"Configs":[{"Id":"exp-resume"}]}""");
467+
var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest");
468+
var resumeRequest = CreateInternalRequest(
469+
resumeRequestType,
470+
("SessionId", "session-id"),
471+
("ExpAssignments", resumeAssignments.RootElement.Clone()));
472+
473+
var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options);
474+
using var resumeDocument = JsonDocument.Parse(resumeJson);
475+
var resumeRoot = resumeDocument.RootElement;
476+
Assert.Equal("exp-resume", resumeRoot.GetProperty("expAssignments").GetProperty("Configs")[0].GetProperty("Id").GetString());
477+
}
478+
479+
[Fact]
480+
public void SessionRequests_OmitExpAssignments_WhenUnset()
481+
{
482+
var options = GetSerializerOptions();
483+
484+
var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest");
485+
var createRequest = CreateInternalRequest(
486+
createRequestType,
487+
("SessionId", "session-id"));
488+
489+
var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options);
490+
using var createDocument = JsonDocument.Parse(createJson);
491+
Assert.False(createDocument.RootElement.TryGetProperty("expAssignments", out _));
492+
493+
var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest");
494+
var resumeRequest = CreateInternalRequest(
495+
resumeRequestType,
496+
("SessionId", "session-id"));
497+
498+
var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options);
499+
using var resumeDocument = JsonDocument.Parse(resumeJson);
500+
Assert.False(resumeDocument.RootElement.TryGetProperty("expAssignments", out _));
501+
}
502+
503+
[Fact]
504+
public void SessionConfigClone_PreservesExpAssignments()
505+
{
506+
using var assignments = JsonDocument.Parse("""{"Configs":[{"Id":"exp-create"}]}""");
507+
508+
var config = new SessionConfig
509+
{
510+
SessionId = "session-id",
511+
ExpAssignments = assignments.RootElement.Clone(),
512+
};
513+
514+
var clone = config.Clone();
515+
516+
Assert.True(clone.ExpAssignments.HasValue);
517+
Assert.Equal(
518+
"exp-create",
519+
clone.ExpAssignments!.Value.GetProperty("Configs")[0].GetProperty("Id").GetString());
520+
}
521+
522+
[Fact]
523+
public void ResumeSessionConfigClone_PreservesExpAssignments()
524+
{
525+
using var assignments = JsonDocument.Parse("""{"Configs":[{"Id":"exp-resume"}]}""");
526+
527+
var config = new ResumeSessionConfig
528+
{
529+
ExpAssignments = assignments.RootElement.Clone(),
530+
};
531+
532+
var clone = config.Clone();
533+
534+
Assert.True(clone.ExpAssignments.HasValue);
535+
Assert.Equal(
536+
"exp-resume",
537+
clone.ExpAssignments!.Value.GetProperty("Configs")[0].GetProperty("Id").GetString());
538+
}
539+
449540
[Fact]
450541
public void CreateSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptions()
451542
{

go/client.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
703703
req.RequestCanvasRenderer = config.RequestCanvasRenderer
704704
req.RequestExtensions = config.RequestExtensions
705705
req.ExtensionSDKPath = config.ExtensionSDKPath
706+
req.ExpAssignments = config.ExpAssignments
706707

707708
if len(config.Commands) > 0 {
708709
cmds := make([]wireCommand, 0, len(config.Commands))
@@ -1043,6 +1044,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
10431044
req.RequestCanvasRenderer = config.RequestCanvasRenderer
10441045
req.RequestExtensions = config.RequestExtensions
10451046
req.ExtensionSDKPath = config.ExtensionSDKPath
1047+
req.ExpAssignments = config.ExpAssignments
10461048
if config.OnPermissionRequest != nil {
10471049
req.RequestPermission = Bool(true)
10481050
}

go/client_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2220,3 +2220,85 @@ func TestStartCLIServer_StderrFieldSet(t *testing.T) {
22202220
t.Error("expected Stderr to be *truncbuffer.TruncBuffer after assignment")
22212221
}
22222222
}
2223+
2224+
func TestCreateSessionRequest_ExpAssignments(t *testing.T) {
2225+
assignments := map[string]any{
2226+
"Parameters": map[string]any{"copilot_exp_flag": "treatment"},
2227+
"AssignmentContext": "ctx-123",
2228+
}
2229+
2230+
t.Run("includes expAssignments in JSON when set", func(t *testing.T) {
2231+
req := createSessionRequest{ExpAssignments: assignments}
2232+
data, err := json.Marshal(req)
2233+
if err != nil {
2234+
t.Fatalf("Failed to marshal: %v", err)
2235+
}
2236+
var m map[string]any
2237+
if err := json.Unmarshal(data, &m); err != nil {
2238+
t.Fatalf("Failed to unmarshal: %v", err)
2239+
}
2240+
got, ok := m["expAssignments"].(map[string]any)
2241+
if !ok {
2242+
t.Fatalf("Expected expAssignments to be an object, got %v", m["expAssignments"])
2243+
}
2244+
if got["AssignmentContext"] != "ctx-123" {
2245+
t.Errorf("Expected AssignmentContext 'ctx-123', got %v", got["AssignmentContext"])
2246+
}
2247+
})
2248+
2249+
t.Run("omits expAssignments from JSON when nil", func(t *testing.T) {
2250+
req := createSessionRequest{}
2251+
data, err := json.Marshal(req)
2252+
if err != nil {
2253+
t.Fatalf("Failed to marshal: %v", err)
2254+
}
2255+
var m map[string]any
2256+
if err := json.Unmarshal(data, &m); err != nil {
2257+
t.Fatalf("Failed to unmarshal: %v", err)
2258+
}
2259+
if _, ok := m["expAssignments"]; ok {
2260+
t.Error("Expected expAssignments to be omitted when nil")
2261+
}
2262+
})
2263+
}
2264+
2265+
func TestResumeSessionRequest_ExpAssignments(t *testing.T) {
2266+
assignments := map[string]any{
2267+
"Parameters": map[string]any{"copilot_exp_flag": "treatment"},
2268+
"AssignmentContext": "ctx-456",
2269+
}
2270+
2271+
t.Run("includes expAssignments in JSON when set", func(t *testing.T) {
2272+
req := resumeSessionRequest{SessionID: "s1", ExpAssignments: assignments}
2273+
data, err := json.Marshal(req)
2274+
if err != nil {
2275+
t.Fatalf("Failed to marshal: %v", err)
2276+
}
2277+
var m map[string]any
2278+
if err := json.Unmarshal(data, &m); err != nil {
2279+
t.Fatalf("Failed to unmarshal: %v", err)
2280+
}
2281+
got, ok := m["expAssignments"].(map[string]any)
2282+
if !ok {
2283+
t.Fatalf("Expected expAssignments to be an object, got %v", m["expAssignments"])
2284+
}
2285+
if got["AssignmentContext"] != "ctx-456" {
2286+
t.Errorf("Expected AssignmentContext 'ctx-456', got %v", got["AssignmentContext"])
2287+
}
2288+
})
2289+
2290+
t.Run("omits expAssignments from JSON when nil", func(t *testing.T) {
2291+
req := resumeSessionRequest{SessionID: "s1"}
2292+
data, err := json.Marshal(req)
2293+
if err != nil {
2294+
t.Fatalf("Failed to marshal: %v", err)
2295+
}
2296+
var m map[string]any
2297+
if err := json.Unmarshal(data, &m); err != nil {
2298+
t.Fatalf("Failed to unmarshal: %v", err)
2299+
}
2300+
if _, ok := m["expAssignments"]; ok {
2301+
t.Error("Expected expAssignments to be omitted when nil")
2302+
}
2303+
})
2304+
}

go/types.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,18 @@ type SessionConfig struct {
11471147
CanvasHandler CanvasHandler `json:"-"`
11481148
// ExtensionInfo identifies the stable extension providing this session's canvases.
11491149
ExtensionInfo *ExtensionInfo
1150+
// ExpAssignments injects ExP assignment ("flight") data for this session,
1151+
// in the same JSON shape the Copilot CLI fetches from the experimentation
1152+
// service (CopilotExpAssignmentResponse). When supplied, the runtime feeds
1153+
// it into the same feature-flag path as CLI-fetched assignments and stamps
1154+
// it onto telemetry and the CAPI request header. When absent, the session
1155+
// does not block on ExP. Malformed payloads are dropped by the runtime
1156+
// (fail-open).
1157+
//
1158+
// Internal: ExpAssignments is part of the SDK's internal API surface,
1159+
// intended for trusted out-of-process integrators, and is not intended for
1160+
// general external use.
1161+
ExpAssignments any
11501162
}
11511163

11521164
// ToolDefer controls whether a tool may be deferred (loaded lazily via tool
@@ -1542,6 +1554,14 @@ type ResumeSessionConfig struct {
15421554
CanvasHandler CanvasHandler `json:"-"`
15431555
// ExtensionInfo identifies the stable extension providing this session's canvases.
15441556
ExtensionInfo *ExtensionInfo
1557+
// ExpAssignments injects ExP assignment ("flight") data on resume. See
1558+
// SessionConfig.ExpAssignments. Re-supply on resume so the runtime
1559+
// re-applies the assignments after a CLI process restart.
1560+
//
1561+
// Internal: ExpAssignments is part of the SDK's internal API surface,
1562+
// intended for trusted out-of-process integrators, and is not intended for
1563+
// general external use.
1564+
ExpAssignments any
15451565
}
15461566
type ProviderConfig struct {
15471567
// Type is the provider type: "openai", "azure", or "anthropic". Defaults to "openai".
@@ -1892,6 +1912,7 @@ type createSessionRequest struct {
18921912
RequestExtensions *bool `json:"requestExtensions,omitempty"`
18931913
ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"`
18941914
ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"`
1915+
ExpAssignments any `json:"expAssignments,omitempty"`
18951916
Traceparent string `json:"traceparent,omitempty"`
18961917
Tracestate string `json:"tracestate,omitempty"`
18971918
}
@@ -1976,6 +1997,7 @@ type resumeSessionRequest struct {
19761997
RequestExtensions *bool `json:"requestExtensions,omitempty"`
19771998
ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"`
19781999
ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"`
2000+
ExpAssignments any `json:"expAssignments,omitempty"`
19792001
Traceparent string `json:"traceparent,omitempty"`
19802002
Tracestate string `json:"tracestate,omitempty"`
19812003
}

java/src/main/java/com/github/copilot/SessionRequestBuilder.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess
177177
request.setGitHubToken(config.getGitHubToken());
178178
request.setRemoteSession(config.getRemoteSession());
179179
request.setCloud(config.getCloud());
180+
request.setExpAssignments(config.getExpAssignments());
180181

181182
return request;
182183
}
@@ -294,6 +295,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo
294295
}
295296
request.setGitHubToken(config.getGitHubToken());
296297
request.setRemoteSession(config.getRemoteSession());
298+
request.setExpAssignments(config.getExpAssignments());
297299

298300
return request;
299301
}

java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import com.fasterxml.jackson.annotation.JsonInclude;
1212
import com.fasterxml.jackson.annotation.JsonProperty;
13+
import com.fasterxml.jackson.databind.JsonNode;
1314

1415
import com.github.copilot.CopilotExperimental;
1516

@@ -195,6 +196,9 @@ public final class CreateSessionRequest {
195196
@JsonProperty("cloud")
196197
private CloudSessionOptions cloud;
197198

199+
@JsonProperty("expAssignments")
200+
private JsonNode expAssignments;
201+
198202
/** Gets the model name. @return the model */
199203
public String getModel() {
200204
return model;
@@ -884,4 +888,16 @@ public CloudSessionOptions getCloud() {
884888
public void setCloud(CloudSessionOptions cloud) {
885889
this.cloud = cloud;
886890
}
891+
892+
/** Gets the ExP assignment data. @return the ExP assignment data */
893+
public JsonNode getExpAssignments() {
894+
return expAssignments;
895+
}
896+
897+
/**
898+
* Sets the ExP assignment data. @param expAssignments the ExP assignment data
899+
*/
900+
public void setExpAssignments(JsonNode expAssignments) {
901+
this.expAssignments = expAssignments;
902+
}
887903
}

0 commit comments

Comments
 (0)