From 15d951300c63ceffc458d4269223b27c36d6477a Mon Sep 17 00:00:00 2001 From: Devraj Mehta Date: Sun, 19 Jul 2026 11:01:55 -0400 Subject: [PATCH 1/5] Strongly type expAssignments session config across all SDKs Replace the opaque JSON typing of the internal `expAssignments` session-config field with a strongly-typed `CopilotExpAssignmentResponse` (plus `ExpConfigEntry`) in every SDK, mirroring the runtime contract. Wire keys remain PascalCase (Features, Flights, Configs, Id, Parameters, ...), optional fields are omitted when null, and the field keeps its internal/hidden posture in each language. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 43d7a02b-08ff-4e7d-a8c0-afa6dfbe94b0 --- dotnet/src/Client.cs | 4 +- dotnet/src/Types.cs | 62 +++++- dotnet/test/Unit/SerializationTests.cs | 39 ++-- go/client_test.go | 20 +- go/internal/e2e/client_options_e2e_test.go | 8 +- go/types.go | 46 ++++- .../rpc/CopilotExpAssignmentResponse.java | 195 ++++++++++++++++++ .../copilot/rpc/CreateSessionRequest.java | 7 +- .../github/copilot/rpc/ExpConfigEntry.java | 72 +++++++ .../copilot/rpc/ResumeSessionConfig.java | 14 +- .../copilot/rpc/ResumeSessionRequest.java | 7 +- .../com/github/copilot/rpc/SessionConfig.java | 13 +- .../copilot/SessionRequestBuilderTest.java | 15 +- nodejs/src/index.ts | 3 + nodejs/src/types.ts | 41 +++- nodejs/test/client.test.ts | 4 +- python/copilot/__init__.py | 6 + python/copilot/client.py | 75 ++++++- python/test_client.py | 24 ++- rust/src/types.rs | 152 +++++++++++--- rust/src/wire.rs | 4 +- rust/tests/e2e/client_options.rs | 26 ++- 22 files changed, 729 insertions(+), 108 deletions(-) create mode 100644 java/src/main/java/com/github/copilot/rpc/CopilotExpAssignmentResponse.java create mode 100644 java/src/main/java/com/github/copilot/rpc/ExpConfigEntry.java diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a512978225..f75b75659e 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -2758,7 +2758,7 @@ internal record CreateSessionRequest( IList? Providers = null, IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, - [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null, + [property: JsonPropertyName("expAssignments")] CopilotExpAssignmentResponse? ExpAssignments = null, [property: JsonPropertyName("enableManagedSettings")] bool? EnableManagedSettings = null, bool? EnableGitHubTelemetryForwarding = null); #pragma warning restore GHCP001 @@ -2864,7 +2864,7 @@ internal record ResumeSessionRequest( IList? Providers = null, IList? Models = null, OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, - [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null, + [property: JsonPropertyName("expAssignments")] CopilotExpAssignmentResponse? ExpAssignments = null, [property: JsonPropertyName("enableManagedSettings")] bool? EnableManagedSettings = null, bool? EnableGitHubTelemetryForwarding = null); #pragma warning restore GHCP001 diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index f893baf5e5..c2ac26e270 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2825,6 +2825,64 @@ public struct SetModelOptions public ModelCapabilitiesOverride? ModelCapabilities { get; set; } } +/// +/// A single configuration entry in a . +/// Each entry carries an identifier and a bag of typed parameter values. +/// +public sealed class ExpConfigEntry +{ + /// Identifier of the configuration entry. + [JsonPropertyName("Id")] + public string Id { get; set; } = string.Empty; + + /// + /// Parameter values keyed by parameter name. Each value is a string, number, + /// boolean, or null. + /// + [JsonPropertyName("Parameters")] + public IDictionary Parameters { get; set; } = new Dictionary(); +} + +/// +/// ExP ("flight") assignment data, in the same JSON shape the Copilot CLI +/// fetches from the experimentation service. Property names serialize as +/// PascalCase (Features, Flights, ...) to match the on-the-wire +/// contract consumed by the runtime. +/// +public sealed class CopilotExpAssignmentResponse +{ + /// Enabled feature names. + [JsonPropertyName("Features")] + public IList Features { get; set; } = new List(); + + /// Assigned flights keyed by flight name. + [JsonPropertyName("Flights")] + public IDictionary Flights { get; set; } = new Dictionary(); + + /// Configuration entries carrying typed parameter values. + [JsonPropertyName("Configs")] + public IList Configs { get; set; } = new List(); + + /// Opaque parameter-group payload passed through untouched. Optional. + [JsonPropertyName("ParameterGroups")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonNode? ParameterGroups { get; set; } + + /// Version of the flighting configuration. Optional. + [JsonPropertyName("FlightingVersion")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? FlightingVersion { get; set; } + + /// Impression identifier for the assignment. Optional. + [JsonPropertyName("ImpressionId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ImpressionId { get; set; } + + /// Assignment context string forwarded to CAPI and telemetry. + [JsonPropertyName("AssignmentContext")] + public string AssignmentContext { get; set; } = string.Empty; +} + /// /// Shared configuration properties for creating or resuming a Copilot session. /// Use when creating a new session, or @@ -3331,7 +3389,7 @@ protected SessionConfigBase(SessionConfigBase? other) /// completion. It is not part of the broadly advertised public surface. /// [EditorBrowsable(EditorBrowsableState.Never)] - public JsonElement? ExpAssignments { get; set; } + public CopilotExpAssignmentResponse? ExpAssignments { get; set; } /// /// Opt-in: when true, the runtime self-fetches enterprise managed @@ -4033,6 +4091,8 @@ public sealed class SystemMessageTransformRpcResponse [JsonSerializable(typeof(AutoModeSwitchRequest))] [JsonSerializable(typeof(AutoModeSwitchResponse))] [JsonSerializable(typeof(CustomAgentConfig))] +[JsonSerializable(typeof(CopilotExpAssignmentResponse))] +[JsonSerializable(typeof(ExpConfigEntry))] [JsonSerializable(typeof(ExitPlanModeRequest))] [JsonSerializable(typeof(ExitPlanModeResult))] [JsonSerializable(typeof(GetAuthStatusResponse))] diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index ae7e885138..717fefb199 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -3,6 +3,7 @@ *--------------------------------------------------------------------------------------------*/ using Xunit; +using System.Collections.Generic; using System.Text.Json; #if !NET8_0_OR_GREATER using System.Runtime.Serialization; @@ -488,24 +489,28 @@ public void SessionRequests_CanSerializeExpAssignments_WithSdkOptions() { var options = GetSerializerOptions(); - using var createAssignments = JsonDocument.Parse("""{"Configs":[{"Id":"exp-create"}]}"""); var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); var createRequest = CreateInternalRequest( createRequestType, ("SessionId", "session-id"), - ("ExpAssignments", createAssignments.RootElement.Clone())); + ("ExpAssignments", new CopilotExpAssignmentResponse + { + Configs = new List { new() { Id = "exp-create" } }, + })); var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options); using var createDocument = JsonDocument.Parse(createJson); var createRoot = createDocument.RootElement; Assert.Equal("exp-create", createRoot.GetProperty("expAssignments").GetProperty("Configs")[0].GetProperty("Id").GetString()); - using var resumeAssignments = JsonDocument.Parse("""{"Configs":[{"Id":"exp-resume"}]}"""); var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); var resumeRequest = CreateInternalRequest( resumeRequestType, ("SessionId", "session-id"), - ("ExpAssignments", resumeAssignments.RootElement.Clone())); + ("ExpAssignments", new CopilotExpAssignmentResponse + { + Configs = new List { new() { Id = "exp-resume" } }, + })); var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options); using var resumeDocument = JsonDocument.Parse(resumeJson); @@ -540,38 +545,36 @@ public void SessionRequests_OmitExpAssignments_WhenUnset() [Fact] public void SessionConfigClone_PreservesExpAssignments() { - using var assignments = JsonDocument.Parse("""{"Configs":[{"Id":"exp-create"}]}"""); - var config = new SessionConfig { SessionId = "session-id", - ExpAssignments = assignments.RootElement.Clone(), + ExpAssignments = new CopilotExpAssignmentResponse + { + Configs = new List { new() { Id = "exp-create" } }, + }, }; var clone = config.Clone(); - Assert.True(clone.ExpAssignments.HasValue); - Assert.Equal( - "exp-create", - clone.ExpAssignments!.Value.GetProperty("Configs")[0].GetProperty("Id").GetString()); + Assert.NotNull(clone.ExpAssignments); + Assert.Equal("exp-create", clone.ExpAssignments!.Configs[0].Id); } [Fact] public void ResumeSessionConfigClone_PreservesExpAssignments() { - using var assignments = JsonDocument.Parse("""{"Configs":[{"Id":"exp-resume"}]}"""); - var config = new ResumeSessionConfig { - ExpAssignments = assignments.RootElement.Clone(), + ExpAssignments = new CopilotExpAssignmentResponse + { + Configs = new List { new() { Id = "exp-resume" } }, + }, }; var clone = config.Clone(); - Assert.True(clone.ExpAssignments.HasValue); - Assert.Equal( - "exp-resume", - clone.ExpAssignments!.Value.GetProperty("Configs")[0].GetProperty("Id").GetString()); + Assert.NotNull(clone.ExpAssignments); + Assert.Equal("exp-resume", clone.ExpAssignments!.Configs[0].Id); } [Fact] diff --git a/go/client_test.go b/go/client_test.go index b24628d836..ab977fcdb5 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -3181,9 +3181,13 @@ func TestStartCLIServer_StderrFieldSet(t *testing.T) { } func TestCreateSessionRequest_ExpAssignments(t *testing.T) { - assignments := map[string]any{ - "Parameters": map[string]any{"copilot_exp_flag": "treatment"}, - "AssignmentContext": "ctx-123", + assignments := &CopilotExpAssignmentResponse{ + Features: []string{"copilot_exp_flag"}, + Flights: map[string]string{"copilot_exp_flag": "treatment"}, + Configs: []ExpConfigEntry{ + {ID: "cfg-1", Parameters: map[string]ExpFlagValue{"threshold": 5, "enabled": true}}, + }, + AssignmentContext: "ctx-123", } t.Run("includes expAssignments in JSON when set", func(t *testing.T) { @@ -3222,9 +3226,13 @@ func TestCreateSessionRequest_ExpAssignments(t *testing.T) { } func TestResumeSessionRequest_ExpAssignments(t *testing.T) { - assignments := map[string]any{ - "Parameters": map[string]any{"copilot_exp_flag": "treatment"}, - "AssignmentContext": "ctx-456", + assignments := &CopilotExpAssignmentResponse{ + Features: []string{"copilot_exp_flag"}, + Flights: map[string]string{"copilot_exp_flag": "treatment"}, + Configs: []ExpConfigEntry{ + {ID: "cfg-1", Parameters: map[string]ExpFlagValue{"copilot_exp_flag": "treatment"}}, + }, + AssignmentContext: "ctx-456", } t.Run("includes expAssignments in JSON when set", func(t *testing.T) { diff --git a/go/internal/e2e/client_options_e2e_test.go b/go/internal/e2e/client_options_e2e_test.go index 0d3c802b06..1a4879cc57 100644 --- a/go/internal/e2e/client_options_e2e_test.go +++ b/go/internal/e2e/client_options_e2e_test.go @@ -272,7 +272,7 @@ func TestClientOptionsE2E(t *testing.T) { RequestExtensions: copilot.Bool(true), ExtensionSDKPath: &extensionSDKPath, ExtensionInfo: &copilot.ExtensionInfo{Source: "github-app", Name: "go-e2e-extension"}, - ExpAssignments: map[string]any{"feature": "enabled"}, + ExpAssignments: &copilot.CopilotExpAssignmentResponse{Flights: map[string]string{"feature": "enabled"}, AssignmentContext: "ctx"}, }) if err != nil { t.Fatalf("CreateSession failed: %v", err) @@ -342,7 +342,7 @@ func TestClientOptionsE2E(t *testing.T) { if canvas["id"] != "canvas" || canvas["displayName"] != "Canvas" || canvas["description"] != "Canvas description" { t.Fatalf("Expected canvas declaration to be forwarded, got %#v", canvas) } - if params["expAssignments"].(map[string]any)["feature"] != "enabled" { + if params["expAssignments"].(map[string]any)["Flights"].(map[string]any)["feature"] != "enabled" { t.Fatalf("Expected expAssignments to be forwarded, got %#v", params["expAssignments"]) } }) @@ -460,7 +460,7 @@ func TestClientOptionsE2E(t *testing.T) { RequestExtensions: copilot.Bool(true), ExtensionSDKPath: &extensionSDKPath, ExtensionInfo: &copilot.ExtensionInfo{Source: "github-app", Name: "go-e2e-extension"}, - ExpAssignments: map[string]any{"resumeFeature": "enabled"}, + ExpAssignments: &copilot.CopilotExpAssignmentResponse{Flights: map[string]string{"resumeFeature": "enabled"}, AssignmentContext: "ctx"}, }) if err != nil { t.Fatalf("ResumeSession failed: %v", err) @@ -503,7 +503,7 @@ func TestClientOptionsE2E(t *testing.T) { if extensionInfo["source"] != "github-app" || extensionInfo["name"] != "go-e2e-extension" { t.Fatalf("Expected extensionInfo on resume, got %#v", extensionInfo) } - if params["expAssignments"].(map[string]any)["resumeFeature"] != "enabled" { + if params["expAssignments"].(map[string]any)["Flights"].(map[string]any)["resumeFeature"] != "enabled" { t.Fatalf("Expected resume expAssignments to be forwarded, got %#v", params["expAssignments"]) } }) diff --git a/go/types.go b/go/types.go index d487d6d8ab..c942d9a94d 100644 --- a/go/types.go +++ b/go/types.go @@ -1026,6 +1026,44 @@ type SessionFSConfig struct { Capabilities *SessionFSCapabilities } +// ExpFlagValue is a single ExP (Experiment Platform) flag value. ExP +// assignments resolve to a string, number (float64/int), bool, or nil. +type ExpFlagValue any + +// ExpConfigEntry is a single configuration entry in a +// [CopilotExpAssignmentResponse]. Each entry carries an identifier and a bag of +// typed parameter values. +type ExpConfigEntry struct { + // ID identifies the configuration entry. Serialized on the wire as "Id". + ID string `json:"Id"` + // Parameters holds parameter values keyed by parameter name. + Parameters map[string]ExpFlagValue `json:"Parameters"` +} + +// CopilotExpAssignmentResponse is ExP ("flight") assignment data, in the same +// JSON shape the Copilot CLI fetches from the experimentation service. Field +// names are PascalCase to match the on-the-wire contract consumed by the +// runtime. +type CopilotExpAssignmentResponse struct { + // Features lists the enabled feature names. + Features []string `json:"Features"` + // Flights holds the assigned flights keyed by flight name. + Flights map[string]string `json:"Flights"` + // Configs holds configuration entries carrying typed parameter values. + Configs []ExpConfigEntry `json:"Configs"` + // ParameterGroups is an opaque parameter-group payload passed through + // untouched. Optional. + ParameterGroups any `json:"ParameterGroups,omitempty"` + // FlightingVersion is the version of the flighting configuration. Optional. + FlightingVersion *int `json:"FlightingVersion,omitempty"` + // ImpressionID is the impression identifier for the assignment. Optional. + // Serialized on the wire as "ImpressionId". + ImpressionID *string `json:"ImpressionId,omitempty"` + // AssignmentContext is the assignment context string forwarded to CAPI and + // telemetry. + AssignmentContext string `json:"AssignmentContext"` +} + // SessionConfig configures a new session type SessionConfig struct { // SessionID is an optional custom session ID @@ -1312,7 +1350,7 @@ type SessionConfig struct { // Internal: ExpAssignments is part of the SDK's internal API surface, // intended for trusted out-of-process integrators, and is not intended for // general external use. - ExpAssignments any + ExpAssignments *CopilotExpAssignmentResponse // EnableManagedSettings, when set to true, opts the runtime into // self-fetching enterprise managed settings (bypass-permissions policy) at // session bootstrap using the session's GitHubToken. Requires GitHubToken to @@ -1761,7 +1799,7 @@ type ResumeSessionConfig struct { // Internal: ExpAssignments is part of the SDK's internal API surface, // intended for trusted out-of-process integrators, and is not intended for // general external use. - ExpAssignments any + ExpAssignments *CopilotExpAssignmentResponse // EnableManagedSettings injects the same opt-in flag on resume. See // SessionConfig.EnableManagedSettings. Re-supply on resume so the runtime // re-applies the managed-settings self-fetch after a CLI process restart. @@ -2221,7 +2259,7 @@ type createSessionRequest struct { ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"` ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"` - ExpAssignments any `json:"expAssignments,omitempty"` + ExpAssignments *CopilotExpAssignmentResponse `json:"expAssignments,omitempty"` EnableManagedSettings *bool `json:"enableManagedSettings,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` @@ -2313,7 +2351,7 @@ type resumeSessionRequest struct { ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"` ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"` CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"` - ExpAssignments any `json:"expAssignments,omitempty"` + ExpAssignments *CopilotExpAssignmentResponse `json:"expAssignments,omitempty"` EnableManagedSettings *bool `json:"enableManagedSettings,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` diff --git a/java/src/main/java/com/github/copilot/rpc/CopilotExpAssignmentResponse.java b/java/src/main/java/com/github/copilot/rpc/CopilotExpAssignmentResponse.java new file mode 100644 index 0000000000..df280bd2dd --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/CopilotExpAssignmentResponse.java @@ -0,0 +1,195 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * ExP ("flight") assignment data, in the same JSON shape the Copilot CLI + * fetches from the experimentation service. + *

+ * Property names serialize as PascalCase ({@code Features}, {@code Flights}, + * {@code Configs}, ...) to match the on-the-wire contract consumed by the + * runtime. This is an internal/trusted-integrator option, not part of the + * broadly advertised public surface. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CopilotExpAssignmentResponse { + + @JsonProperty("Features") + private List features = new ArrayList<>(); + + @JsonProperty("Flights") + private Map flights = new LinkedHashMap<>(); + + @JsonProperty("Configs") + private List configs = new ArrayList<>(); + + @JsonProperty("ParameterGroups") + private JsonNode parameterGroups; + + @JsonProperty("FlightingVersion") + private Integer flightingVersion; + + @JsonProperty("ImpressionId") + private String impressionId; + + @JsonProperty("AssignmentContext") + private String assignmentContext; + + /** + * Gets the enabled feature names. + * + * @return the feature list + */ + public List getFeatures() { + return features; + } + + /** + * Sets the enabled feature names. + * + * @param features + * the feature list + * @return this instance for method chaining + */ + public CopilotExpAssignmentResponse setFeatures(List features) { + this.features = features; + return this; + } + + /** + * Gets the assigned flights keyed by flight name. + * + * @return the flights map + */ + public Map getFlights() { + return flights; + } + + /** + * Sets the assigned flights keyed by flight name. + * + * @param flights + * the flights map + * @return this instance for method chaining + */ + public CopilotExpAssignmentResponse setFlights(Map flights) { + this.flights = flights; + return this; + } + + /** + * Gets the configuration entries carrying typed parameter values. + * + * @return the configuration entries + */ + public List getConfigs() { + return configs; + } + + /** + * Sets the configuration entries carrying typed parameter values. + * + * @param configs + * the configuration entries + * @return this instance for method chaining + */ + public CopilotExpAssignmentResponse setConfigs(List configs) { + this.configs = configs; + return this; + } + + /** + * Gets the opaque parameter-group payload passed through untouched. + * + * @return the parameter groups, or {@code null} if not set + */ + public JsonNode getParameterGroups() { + return parameterGroups; + } + + /** + * Sets the opaque parameter-group payload passed through untouched. + * + * @param parameterGroups + * the parameter groups + * @return this instance for method chaining + */ + public CopilotExpAssignmentResponse setParameterGroups(JsonNode parameterGroups) { + this.parameterGroups = parameterGroups; + return this; + } + + /** + * Gets the version of the flighting configuration. + * + * @return the flighting version, or {@code null} if not set + */ + public Integer getFlightingVersion() { + return flightingVersion; + } + + /** + * Sets the version of the flighting configuration. + * + * @param flightingVersion + * the flighting version + * @return this instance for method chaining + */ + public CopilotExpAssignmentResponse setFlightingVersion(Integer flightingVersion) { + this.flightingVersion = flightingVersion; + return this; + } + + /** + * Gets the impression identifier for the assignment. + * + * @return the impression identifier, or {@code null} if not set + */ + public String getImpressionId() { + return impressionId; + } + + /** + * Sets the impression identifier for the assignment. + * + * @param impressionId + * the impression identifier + * @return this instance for method chaining + */ + public CopilotExpAssignmentResponse setImpressionId(String impressionId) { + this.impressionId = impressionId; + return this; + } + + /** + * Gets the assignment context string forwarded to CAPI and telemetry. + * + * @return the assignment context, or {@code null} if not set + */ + public String getAssignmentContext() { + return assignmentContext; + } + + /** + * Sets the assignment context string forwarded to CAPI and telemetry. + * + * @param assignmentContext + * the assignment context + * @return this instance for method chaining + */ + public CopilotExpAssignmentResponse setAssignmentContext(String assignmentContext) { + this.assignmentContext = assignmentContext; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index f2ce097a13..9a9f4f152f 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -10,7 +10,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.JsonNode; import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.rpc.SessionLimitsConfig; @@ -213,7 +212,7 @@ public final class CreateSessionRequest { private CloudSessionOptions cloud; @JsonProperty("expAssignments") - private JsonNode expAssignments; + private CopilotExpAssignmentResponse expAssignments; @JsonProperty("enableManagedSettings") @JsonInclude(JsonInclude.Include.NON_NULL) @@ -973,14 +972,14 @@ public void setCloud(CloudSessionOptions cloud) { } /** Gets the ExP assignment data. @return the ExP assignment data */ - public JsonNode getExpAssignments() { + public CopilotExpAssignmentResponse getExpAssignments() { return expAssignments; } /** * Sets the ExP assignment data. @param expAssignments the ExP assignment data */ - public void setExpAssignments(JsonNode expAssignments) { + public void setExpAssignments(CopilotExpAssignmentResponse expAssignments) { this.expAssignments = expAssignments; } diff --git a/java/src/main/java/com/github/copilot/rpc/ExpConfigEntry.java b/java/src/main/java/com/github/copilot/rpc/ExpConfigEntry.java new file mode 100644 index 0000000000..c49b498f15 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/ExpConfigEntry.java @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A single configuration entry within a {@link CopilotExpAssignmentResponse}. + *

+ * Each entry carries an identifier and a bag of typed parameter values, where + * each value is a string, number, boolean, or {@code null}. Property names + * serialize as PascalCase to match the experimentation-service wire contract. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ExpConfigEntry { + + @JsonProperty("Id") + private String id; + + @JsonProperty("Parameters") + private Map parameters = new LinkedHashMap<>(); + + /** + * Gets the identifier of this configuration entry. + * + * @return the entry identifier, or {@code null} if not set + */ + public String getId() { + return id; + } + + /** + * Sets the identifier of this configuration entry. + * + * @param id + * the entry identifier + * @return this instance for method chaining + */ + public ExpConfigEntry setId(String id) { + this.id = id; + return this; + } + + /** + * Gets the parameter values keyed by parameter name. Each value is a string, + * number, boolean, or {@code null}. + * + * @return the parameter map + */ + public Map getParameters() { + return parameters; + } + + /** + * Sets the parameter values keyed by parameter name. Each value is a string, + * number, boolean, or {@code null}. + * + * @param parameters + * the parameter map + * @return this instance for method chaining + */ + public ExpConfigEntry setParameters(Map parameters) { + this.parameters = parameters; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 6f3c315658..a3dd336cf5 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -13,7 +13,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.databind.JsonNode; import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.SessionEvent; @@ -102,7 +101,7 @@ public class ResumeSessionConfig { private boolean enableMcpApps; private String gitHubToken; private String remoteSession; - private JsonNode expAssignments; + private CopilotExpAssignmentResponse expAssignments; private Boolean enableManagedSettings; /** @@ -1749,21 +1748,22 @@ public ResumeSessionConfig setRemoteSession(String remoteSession) { * * @return the ExP assignment data, or {@code null} if not set */ - public JsonNode getExpAssignments() { + public CopilotExpAssignmentResponse getExpAssignments() { return expAssignments; } /** * Sets ExP assignment ("flight") data injected by a trusted integrator. *

- * See {@link SessionConfig#setExpAssignments(JsonNode)} for details. The - * runtime supports injecting ExP assignments on resume as well as create. + * See {@link SessionConfig#setExpAssignments(CopilotExpAssignmentResponse)} for + * details. The runtime supports injecting ExP assignments on resume as well as + * create. * * @param expAssignments - * the opaque ExP assignment data + * the ExP assignment data * @return this config for method chaining */ - public ResumeSessionConfig setExpAssignments(JsonNode expAssignments) { + public ResumeSessionConfig setExpAssignments(CopilotExpAssignmentResponse expAssignments) { this.expAssignments = expAssignments; return this; } diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index ab848118e6..a81ed49dde 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -10,7 +10,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.JsonNode; import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.rpc.SessionLimitsConfig; @@ -215,7 +214,7 @@ public final class ResumeSessionRequest { private String remoteSession; @JsonProperty("expAssignments") - private JsonNode expAssignments; + private CopilotExpAssignmentResponse expAssignments; @JsonProperty("enableManagedSettings") @JsonInclude(JsonInclude.Include.NON_NULL) @@ -988,14 +987,14 @@ public void setRemoteSession(String remoteSession) { } /** Gets the ExP assignment data. @return the ExP assignment data */ - public JsonNode getExpAssignments() { + public CopilotExpAssignmentResponse getExpAssignments() { return expAssignments; } /** * Sets the ExP assignment data. @param expAssignments the ExP assignment data */ - public void setExpAssignments(JsonNode expAssignments) { + public void setExpAssignments(CopilotExpAssignmentResponse expAssignments) { this.expAssignments = expAssignments; } diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index e611f66c89..0e02482de4 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -13,7 +13,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.databind.JsonNode; import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.SessionEvent; @@ -103,7 +102,7 @@ public class SessionConfig { private String gitHubToken; private String remoteSession; private CloudSessionOptions cloud; - private JsonNode expAssignments; + private CopilotExpAssignmentResponse expAssignments; private Boolean enableManagedSettings; /** @@ -1873,15 +1872,15 @@ public SessionConfig setCloud(CloudSessionOptions cloud) { * * @return the ExP assignment data, or {@code null} if not set */ - public JsonNode getExpAssignments() { + public CopilotExpAssignmentResponse getExpAssignments() { return expAssignments; } /** * Sets ExP assignment ("flight") data injected by a trusted integrator. *

- * The value is opaque JSON in the same shape the Copilot CLI fetches from the - * experimentation service ({@code CopilotExpAssignmentResponse}). When + * The value is in the same shape the Copilot CLI fetches from the + * experimentation service ({@link CopilotExpAssignmentResponse}). When * provided, the runtime feeds it into the same feature-flag path as CLI-fetched * assignments and stamps it onto telemetry and the CAPI request header. When * absent, the session does not block on ExP. Intended for out-of-process @@ -1892,10 +1891,10 @@ public JsonNode getExpAssignments() { * advertised public surface. * * @param expAssignments - * the opaque ExP assignment data + * the ExP assignment data * @return this config instance for method chaining */ - public SessionConfig setExpAssignments(JsonNode expAssignments) { + public SessionConfig setExpAssignments(CopilotExpAssignmentResponse expAssignments) { this.expAssignments = expAssignments; return this; } diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index bf192e98a3..652be026b1 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -12,17 +12,18 @@ import org.junit.jupiter.api.Test; -import com.fasterxml.jackson.databind.JsonNode; import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.AutoModeSwitchResponse; import com.github.copilot.rpc.CloudSessionOptions; import com.github.copilot.rpc.CloudSessionRepository; +import com.github.copilot.rpc.CopilotExpAssignmentResponse; import com.github.copilot.rpc.CreateSessionRequest; import com.github.copilot.rpc.DefaultAgentConfig; import com.github.copilot.rpc.ElicitationHandler; import com.github.copilot.rpc.ElicitationResult; import com.github.copilot.rpc.ElicitationResultAction; import com.github.copilot.rpc.ExitPlanModeResult; +import com.github.copilot.rpc.ExpConfigEntry; import com.github.copilot.rpc.LargeToolOutputConfig; import com.github.copilot.rpc.MemoryConfiguration; import com.github.copilot.rpc.ResumeSessionConfig; @@ -899,8 +900,10 @@ void testCloudSessionOptionsSerializesCorrectly() throws Exception { @Test void testBuildRequestsPropagateAndSerializeExpAssignments() throws Exception { var mapper = JsonRpcClient.getObjectMapper(); - JsonNode createAssignments = mapper.readTree("{\"Configs\":[{\"Id\":\"exp-create\"}]}"); - JsonNode resumeAssignments = mapper.readTree("{\"Configs\":[{\"Id\":\"exp-resume\"}]}"); + var createAssignments = new CopilotExpAssignmentResponse() + .setConfigs(List.of(new ExpConfigEntry().setId("exp-create"))); + var resumeAssignments = new CopilotExpAssignmentResponse() + .setConfigs(List.of(new ExpConfigEntry().setId("exp-resume"))); var createConfig = new SessionConfig().setExpAssignments(createAssignments); CreateSessionRequest createRequest = SessionRequestBuilder.buildCreateRequest(createConfig, "session-1"); @@ -936,8 +939,10 @@ void testBuildRequestsOmitExpAssignmentsWhenUnset() throws Exception { @Test void testClonePreservesAndForwardsExpAssignments() throws Exception { var mapper = JsonRpcClient.getObjectMapper(); - JsonNode createAssignments = mapper.readTree("{\"Configs\":[{\"Id\":\"exp-create\"}]}"); - JsonNode resumeAssignments = mapper.readTree("{\"Configs\":[{\"Id\":\"exp-resume\"}]}"); + var createAssignments = new CopilotExpAssignmentResponse() + .setConfigs(List.of(new ExpConfigEntry().setId("exp-create"))); + var resumeAssignments = new CopilotExpAssignmentResponse() + .setConfigs(List.of(new ExpConfigEntry().setId("exp-resume"))); var createConfig = new SessionConfig().setExpAssignments(createAssignments); SessionConfig createClone = createConfig.clone(); diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 9d3bdcd7f0..c1ab289150 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -59,6 +59,7 @@ export type { AutoModeSwitchResponse, CopilotClientMode, CopilotClientOptions, + CopilotExpAssignmentResponse, StdioRuntimeConnection, InProcessRuntimeConnection, TcpRuntimeConnection, @@ -72,6 +73,8 @@ export type { ElicitationResult, ElicitationSchema, ElicitationSchemaField, + ExpConfigEntry, + ExpFlagValue, ExitPlanModeHandler, ExitPlanModeRequest, ExitPlanModeResult, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 7fecef2e6c..e7894d4d9c 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1855,6 +1855,45 @@ export interface CapiSessionOptions { enableWebSocketResponses?: boolean; } +/** + * A single ExP (Experiment Platform) flag value. ExP assignments resolve to a + * string, number, boolean, or `null`. + */ +export type ExpFlagValue = string | number | boolean | null; + +/** + * A single configuration entry in a {@link CopilotExpAssignmentResponse}. Each + * entry carries an identifier and a bag of typed parameter values. + */ +export interface ExpConfigEntry { + /** Identifier of the configuration entry. */ + Id: string; + /** Parameter values keyed by parameter name. */ + Parameters: Record; +} + +/** + * ExP ("flight") assignment data, in the same JSON shape the Copilot CLI + * fetches from the experimentation service. Field names are PascalCase to match + * the on-the-wire contract consumed by the runtime. + */ +export interface CopilotExpAssignmentResponse { + /** Enabled feature names. */ + Features: string[]; + /** Assigned flights keyed by flight name. */ + Flights: Record; + /** Configuration entries carrying typed parameter values. */ + Configs: ExpConfigEntry[]; + /** Opaque parameter-group payload passed through untouched. */ + ParameterGroups?: unknown; + /** Version of the flighting configuration. */ + FlightingVersion?: number; + /** Impression identifier for the assignment. */ + ImpressionId?: string; + /** Assignment context string forwarded to CAPI and telemetry. */ + AssignmentContext: string; +} + /** * Shared configuration fields used by both {@link SessionConfig} (for * creating a new session) and {@link ResumeSessionConfig} (for resuming @@ -2422,7 +2461,7 @@ export interface SessionConfigBase { * * @internal */ - expAssignments?: Record; + expAssignments?: CopilotExpAssignmentResponse; } /** diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 2d93e9e075..6eac95ef26 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -717,7 +717,9 @@ describe("CopilotClient", () => { }); const assignments = { - Parameters: { copilot_exp_flag: "treatment" }, + Features: ["copilot_exp_flag"], + Flights: { copilot_exp_flag: "treatment" }, + Configs: [{ Id: "cfg-1", Parameters: { threshold: 5, enabled: true } }], AssignmentContext: "ctx-123", }; diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 76f79b79f4..6fb59a8957 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -34,6 +34,9 @@ CloudSessionOptions, CloudSessionRepository, CopilotClient, + CopilotExpAssignmentResponse, + ExpConfigEntry, + ExpFlagValue, GetAuthStatusResponse, GetStatusResponse, InProcessRuntimeConnection, @@ -213,6 +216,7 @@ "CommandDefinition", "CopilotClient", "CopilotClientMode", + "CopilotExpAssignmentResponse", "CopilotSession", "CopilotRequestContext", "CopilotRequestHandler", @@ -227,6 +231,8 @@ "ErrorOccurredHandler", "ErrorOccurredHookInput", "ErrorOccurredHookOutput", + "ExpConfigEntry", + "ExpFlagValue", "ExitPlanModeHandler", "ExitPlanModeRequest", "ExitPlanModeResult", diff --git a/python/copilot/client.py b/python/copilot/client.py index a56748478d..392d2fd5cc 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -26,7 +26,7 @@ import time import uuid from collections.abc import Awaitable, Callable, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import UTC, datetime from types import TracebackType from typing import Any, ClassVar, Literal, TypedDict, cast, overload @@ -143,6 +143,71 @@ class CloudSessionOptions: repository: CloudSessionRepository | None = None +ExpFlagValue = str | int | float | bool | None +"""A single ExP (Experiment Platform) flag value. + +ExP assignments resolve to a string, number, boolean, or ``None``. +""" + + +@dataclass +class ExpConfigEntry: + """A single configuration entry in a :class:`CopilotExpAssignmentResponse`. + + Each entry carries an identifier and a bag of typed parameter values. + """ + + id: str + """Identifier of the configuration entry.""" + parameters: dict[str, ExpFlagValue] = field(default_factory=dict) + """Parameter values keyed by parameter name.""" + + +@dataclass +class CopilotExpAssignmentResponse: + """ExP ("flight") assignment data. + + Uses the same JSON shape the Copilot CLI fetches from the experimentation + service. Serialized on the wire with PascalCase keys to match the contract + consumed by the runtime. + """ + + features: list[str] = field(default_factory=list) + """Enabled feature names.""" + flights: dict[str, str] = field(default_factory=dict) + """Assigned flights keyed by flight name.""" + configs: list[ExpConfigEntry] = field(default_factory=list) + """Configuration entries carrying typed parameter values.""" + assignment_context: str = "" + """Assignment context string forwarded to CAPI and telemetry.""" + parameter_groups: Any | None = None + """Opaque parameter-group payload passed through untouched. Optional.""" + flighting_version: int | None = None + """Version of the flighting configuration. Optional.""" + impression_id: str | None = None + """Impression identifier for the assignment. Optional.""" + + +def _exp_assignment_response_to_dict( + response: CopilotExpAssignmentResponse, +) -> dict[str, Any]: + wire: dict[str, Any] = { + "Features": list(response.features), + "Flights": dict(response.flights), + "Configs": [ + {"Id": entry.id, "Parameters": dict(entry.parameters)} for entry in response.configs + ], + "AssignmentContext": response.assignment_context, + } + if response.parameter_groups is not None: + wire["ParameterGroups"] = response.parameter_groups + if response.flighting_version is not None: + wire["FlightingVersion"] = response.flighting_version + if response.impression_id is not None: + wire["ImpressionId"] = response.impression_id + return wire + + class CapiSessionOptions(TypedDict, total=False): """Provider-scoped Copilot API (CAPI) session options.""" @@ -1979,7 +2044,7 @@ async def create_session( extension_info: ExtensionInfo | None = None, canvas_provider: CanvasProviderIdentity | None = None, canvas_handler: CanvasHandler | None = None, - exp_assignments: dict[str, Any] | None = None, + exp_assignments: CopilotExpAssignmentResponse | None = None, enable_managed_settings: bool | None = None, ) -> CopilotSession: """ @@ -2253,7 +2318,7 @@ async def create_session( # Add ExP assignment data if provided (opaque JSON, trusted integrator) if exp_assignments is not None: - payload["expAssignments"] = exp_assignments + payload["expAssignments"] = _exp_assignment_response_to_dict(exp_assignments) # Opt the runtime into self-fetching enterprise managed settings if enable_managed_settings is not None: @@ -2652,7 +2717,7 @@ async def resume_session( canvas_provider: CanvasProviderIdentity | None = None, canvas_handler: CanvasHandler | None = None, open_canvases: list[OpenCanvasInstance] | None = None, - exp_assignments: dict[str, Any] | None = None, + exp_assignments: CopilotExpAssignmentResponse | None = None, enable_managed_settings: bool | None = None, ) -> CopilotSession: """ @@ -2950,7 +3015,7 @@ async def resume_session( # Add ExP assignment data if provided (opaque JSON, trusted integrator) if exp_assignments is not None: - payload["expAssignments"] = exp_assignments + payload["expAssignments"] = _exp_assignment_response_to_dict(exp_assignments) # Opt the runtime into self-fetching enterprise managed settings if enable_managed_settings is not None: diff --git a/python/test_client.py b/python/test_client.py index aac334cd4a..9a45f598c2 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -25,6 +25,8 @@ from copilot.client import ( CloudSessionOptions, CloudSessionRepository, + CopilotExpAssignmentResponse, + ExpConfigEntry, ModelBilling, ModelCapabilities, ModelInfo, @@ -867,8 +869,12 @@ async def mock_request(method, params, **kwargs): client._client.request = mock_request - create_assignments = {"Configs": [{"Id": "exp-create"}]} - resume_assignments = {"Configs": [{"Id": "exp-resume"}]} + create_assignments = CopilotExpAssignmentResponse( + configs=[ExpConfigEntry(id="exp-create")] + ) + resume_assignments = CopilotExpAssignmentResponse( + configs=[ExpConfigEntry(id="exp-resume")] + ) session = await client.create_session( on_permission_request=PermissionHandler.approve_all, @@ -880,8 +886,18 @@ async def mock_request(method, params, **kwargs): exp_assignments=resume_assignments, ) - assert captured["session.create"]["expAssignments"] == create_assignments - assert captured["session.resume"]["expAssignments"] == resume_assignments + assert captured["session.create"]["expAssignments"] == { + "Features": [], + "Flights": {}, + "Configs": [{"Id": "exp-create", "Parameters": {}}], + "AssignmentContext": "", + } + assert captured["session.resume"]["expAssignments"] == { + "Features": [], + "Flights": {}, + "Configs": [{"Id": "exp-resume", "Parameters": {}}], + "AssignmentContext": "", + } finally: await client.force_stop() diff --git a/rust/src/types.rs b/rust/src/types.rs index b34d8fff45..a447f3624f 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1596,6 +1596,67 @@ impl ProviderModelConfig { } } +/// A single ExP (Experiment Platform) flag value. +/// +/// ExP assignments resolve to a string, number, boolean, or null. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ExpFlagValue { + /// A boolean flag value. + Bool(bool), + /// An integer flag value. + Integer(i64), + /// A floating-point flag value. + Float(f64), + /// A string flag value. + String(String), + /// A null flag value. + Null, +} + +/// A single configuration entry in a [`CopilotExpAssignmentResponse`]. +/// +/// Each entry carries an identifier and a bag of typed parameter values. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ExpConfigEntry { + /// Identifier of the configuration entry. + pub id: String, + /// Parameter values keyed by parameter name. + pub parameters: HashMap, +} + +/// ExP ("flight") assignment data, in the same JSON shape the Copilot CLI +/// fetches from the experimentation service. +/// +/// Field names serialize as PascalCase (`Features`, `Flights`, ...) to match +/// the on-the-wire contract consumed by the runtime. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct CopilotExpAssignmentResponse { + /// Enabled feature names. + #[serde(default)] + pub features: Vec, + /// Assigned flights keyed by flight name. + #[serde(default)] + pub flights: HashMap, + /// Configuration entries carrying typed parameter values. + #[serde(default)] + pub configs: Vec, + /// Opaque parameter-group payload passed through untouched. Optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameter_groups: Option, + /// Version of the flighting configuration. Optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flighting_version: Option, + /// Impression identifier for the assignment. Optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub impression_id: Option, + /// Assignment context string forwarded to CAPI and telemetry. + #[serde(default)] + pub assignment_context: String, +} + /// Configuration for creating a new session via the `session.create` RPC. /// /// All fields are optional — the CLI applies sensible defaults. @@ -1866,7 +1927,7 @@ pub struct SessionConfig { /// When absent, the session does not block on ExP. Set via /// [`with_exp_assignments`](Self::with_exp_assignments). #[doc(hidden)] - pub exp_assignments: Option, + pub exp_assignments: Option, /// Opt-in: when `Some(true)`, the runtime self-fetches enterprise managed /// settings (bypass-permissions policy) at session bootstrap using the /// session's [`github_token`](Self::github_token). Requires `github_token` @@ -2855,7 +2916,7 @@ impl SessionConfig { /// integrators that fetch ExP data out of process; malformed payloads /// are dropped by the runtime (fail-open). #[doc(hidden)] - pub fn with_exp_assignments(mut self, assignments: Value) -> Self { + pub fn with_exp_assignments(mut self, assignments: CopilotExpAssignmentResponse) -> Self { self.exp_assignments = Some(assignments); self } @@ -3042,7 +3103,7 @@ pub struct ResumeSessionConfig { /// re-applies the assignments after a CLI process restart. Set via /// [`with_exp_assignments`](Self::with_exp_assignments). #[doc(hidden)] - pub exp_assignments: Option, + pub exp_assignments: Option, /// Opt-in flag injected on resume. See /// [`SessionConfig::enable_managed_settings`]. Re-supply on resume so /// the runtime re-applies the managed-settings self-fetch after a CLI @@ -3981,7 +4042,7 @@ impl ResumeSessionConfig { /// [`SessionConfig::with_exp_assignments`]. Re-supply the assignments on /// resume so the runtime re-applies them after a CLI process restart. #[doc(hidden)] - pub fn with_exp_assignments(mut self, assignments: Value) -> Self { + pub fn with_exp_assignments(mut self, assignments: CopilotExpAssignmentResponse) -> Self { self.exp_assignments = Some(assignments); self } @@ -5415,7 +5476,8 @@ mod tests { use super::{ AgentMode, Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, AzureProviderOptions, CapiSessionOptions, ConnectionState, - CustomAgentConfig, DeliveryMode, ExtensionInfo, GitHubReferenceType, InfiniteSessionConfig, + CopilotExpAssignmentResponse, CustomAgentConfig, DeliveryMode, ExpConfigEntry, + ExpFlagValue, ExtensionInfo, GitHubReferenceType, InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, McpStdioServerConfig, MemoryConfiguration, NamedProviderConfig, ProviderConfig, ProviderModelConfig, ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, @@ -5423,6 +5485,7 @@ mod tests { ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; + use std::collections::HashMap; #[test] fn tool_builder_composes() { @@ -5754,18 +5817,55 @@ mod tests { assert!(empty_json.get("memory").is_none()); } + fn sample_exp_assignments(context: &str) -> CopilotExpAssignmentResponse { + CopilotExpAssignmentResponse { + features: vec!["copilot_exp_flag".to_string()], + flights: HashMap::from([("copilot_exp_flag".to_string(), "treatment".to_string())]), + configs: vec![ExpConfigEntry { + id: "cfg-1".to_string(), + parameters: HashMap::from([ + ("threshold".to_string(), ExpFlagValue::Integer(5)), + ("enabled".to_string(), ExpFlagValue::Bool(true)), + ]), + }], + assignment_context: context.to_string(), + ..Default::default() + } + } + #[test] - fn session_config_with_exp_assignments_serializes() { - let assignments = serde_json::json!({ - "Parameters": { "copilot_exp_flag": "treatment" }, - "AssignmentContext": "ctx-123", + fn exp_flag_value_round_trips_all_variants() { + let values = serde_json::json!({ + "s": "text", + "i": 7, + "f": 1.5, + "b": true, + "n": null, }); + let parsed: HashMap = serde_json::from_value(values.clone()).unwrap(); + assert_eq!(parsed["s"], ExpFlagValue::String("text".to_string())); + assert_eq!(parsed["i"], ExpFlagValue::Integer(7)); + assert_eq!(parsed["f"], ExpFlagValue::Float(1.5)); + assert_eq!(parsed["b"], ExpFlagValue::Bool(true)); + assert_eq!(parsed["n"], ExpFlagValue::Null); + assert_eq!(serde_json::to_value(&parsed).unwrap(), values); + } + + #[test] + fn session_config_with_exp_assignments_serializes() { + let assignments = sample_exp_assignments("ctx-123"); + let expected = serde_json::to_value(&assignments).unwrap(); let (wire, _runtime) = SessionConfig::default() - .with_exp_assignments(assignments.clone()) + .with_exp_assignments(assignments) .into_wire(Some(SessionId::from("exp-on"))) .expect("no duplicate handlers"); let json = serde_json::to_value(&wire).unwrap(); - assert_eq!(json["expAssignments"], assignments); + assert_eq!(json["expAssignments"], expected); + assert_eq!(json["expAssignments"]["AssignmentContext"], "ctx-123"); + assert_eq!( + json["expAssignments"]["Flights"]["copilot_exp_flag"], + "treatment" + ); // Unset exp assignments are omitted on the wire. let (empty_wire, _) = SessionConfig::default() @@ -5777,16 +5877,14 @@ mod tests { #[test] fn resume_session_config_with_exp_assignments_serializes() { - let assignments = serde_json::json!({ - "Parameters": { "copilot_exp_flag": "treatment" }, - "AssignmentContext": "ctx-456", - }); + let assignments = sample_exp_assignments("ctx-456"); + let expected = serde_json::to_value(&assignments).unwrap(); let (wire, _runtime) = ResumeSessionConfig::new(SessionId::from("resume-exp-on")) - .with_exp_assignments(assignments.clone()) + .with_exp_assignments(assignments) .into_wire() .expect("no duplicate handlers"); let json = serde_json::to_value(&wire).unwrap(); - assert_eq!(json["expAssignments"], assignments); + assert_eq!(json["expAssignments"], expected); // Unset exp assignments are omitted on the wire. let (empty_wire, _) = ResumeSessionConfig::new(SessionId::from("resume-exp-unset")) @@ -5798,10 +5896,7 @@ mod tests { #[test] fn session_config_clone_preserves_exp_assignments() { - let assignments = serde_json::json!({ - "Parameters": { "copilot_exp_flag": "treatment" }, - "AssignmentContext": "ctx-clone", - }); + let assignments = sample_exp_assignments("ctx-clone"); let config = SessionConfig::default().with_exp_assignments(assignments.clone()); let cloned = config.clone(); @@ -5811,15 +5906,15 @@ mod tests { .into_wire(Some(SessionId::from("exp-clone"))) .expect("no duplicate handlers"); let json = serde_json::to_value(&wire).unwrap(); - assert_eq!(json["expAssignments"], assignments); + assert_eq!( + json["expAssignments"], + serde_json::to_value(&assignments).unwrap() + ); } #[test] fn resume_session_config_clone_preserves_exp_assignments() { - let assignments = serde_json::json!({ - "Parameters": { "copilot_exp_flag": "treatment" }, - "AssignmentContext": "ctx-clone-resume", - }); + let assignments = sample_exp_assignments("ctx-clone-resume"); let config = ResumeSessionConfig::new(SessionId::from("resume-exp-clone")) .with_exp_assignments(assignments.clone()); let cloned = config.clone(); @@ -5828,7 +5923,10 @@ mod tests { let (wire, _runtime) = cloned.into_wire().expect("no duplicate handlers"); let json = serde_json::to_value(&wire).unwrap(); - assert_eq!(json["expAssignments"], assignments); + assert_eq!( + json["expAssignments"], + serde_json::to_value(&assignments).unwrap() + ); } #[test] diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 43188bc274..2eabbe848d 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -172,7 +172,7 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub commands: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub exp_assignments: Option, + pub exp_assignments: Option, #[serde(skip_serializing_if = "Option::is_none")] pub enable_managed_settings: Option, } @@ -311,7 +311,7 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub continue_pending_work: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub exp_assignments: Option, + pub exp_assignments: Option, #[serde(skip_serializing_if = "Option::is_none")] pub enable_managed_settings: Option, } diff --git a/rust/tests/e2e/client_options.rs b/rust/tests/e2e/client_options.rs index 85ef835025..fc1ceebb83 100644 --- a/rust/tests/e2e/client_options.rs +++ b/rust/tests/e2e/client_options.rs @@ -5,8 +5,8 @@ use github_copilot_sdk::canvas::CanvasDeclaration; use github_copilot_sdk::rpc::{OpenCanvasInstance, RemoteSessionMode}; use github_copilot_sdk::session_events::{ReasoningSummary, SessionLimitsConfig}; use github_copilot_sdk::{ - CliProgram, Client, ClientOptions, ExtensionInfo, ProviderConfig, ResumeSessionConfig, - SessionConfig, SessionId, Transport, + CliProgram, Client, ClientOptions, CopilotExpAssignmentResponse, ExtensionInfo, ProviderConfig, + ResumeSessionConfig, SessionConfig, SessionId, Transport, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -72,7 +72,11 @@ async fn should_forward_advanced_session_creation_options_to_the_cli() { .with_request_extensions(true) .with_extension_sdk_path(path_string(&extension_sdk_path)) .with_extension_info(ExtensionInfo::new("github-app", "rust-e2e-extension")) - .with_exp_assignments(json!({ "feature": "enabled" })), + .with_exp_assignments(CopilotExpAssignmentResponse { + flights: HashMap::from([("feature".to_string(), "enabled".to_string())]), + assignment_context: "ctx".to_string(), + ..Default::default() + }), ) .await .expect("create session"); @@ -135,7 +139,10 @@ async fn should_forward_advanced_session_creation_options_to_the_cli() { params["canvases"][0]["description"], json!("Canvas description") ); - assert_eq!(params["expAssignments"]["feature"], json!("enabled")); + assert_eq!( + params["expAssignments"]["Flights"]["feature"], + json!("enabled") + ); let update = fake.captured_request("session.options.update"); let update_params = update.params.as_object().expect("options update params"); @@ -251,7 +258,11 @@ async fn should_forward_advanced_session_resume_options_to_the_cli() { .with_custom_agents_local_only(true) .with_coauthor_enabled(false) .with_manage_schedule_enabled(false) - .with_exp_assignments(json!({ "resumeFeature": "enabled" })), + .with_exp_assignments(CopilotExpAssignmentResponse { + flights: HashMap::from([("resumeFeature".to_string(), "enabled".to_string())]), + assignment_context: "ctx".to_string(), + ..Default::default() + }), ) .await .expect("resume session"); @@ -298,7 +309,10 @@ async fn should_forward_advanced_session_resume_options_to_the_cli() { params["extensionInfo"], json!({ "source": "github-app", "name": "rust-e2e-extension" }) ); - assert_eq!(params["expAssignments"]["resumeFeature"], json!("enabled")); + assert_eq!( + params["expAssignments"]["Flights"]["resumeFeature"], + json!("enabled") + ); let update = fake.captured_request("session.options.update"); let update_params = update.params.as_object().expect("options update params"); From 562a187eea3989b66e46e405645631ab633ba40a Mon Sep 17 00:00:00 2001 From: Devraj Mehta Date: Sun, 19 Jul 2026 11:21:46 -0400 Subject: [PATCH 2/5] Address review: normalize required exp-assignment fields - Go: add MarshalJSON to CopilotExpAssignmentResponse/ExpConfigEntry so nil Features/Flights/Configs/Parameters serialize as []/{} instead of null, matching the Python/Rust/.NET reference behavior; add a test. - Java: default AssignmentContext to "" so the required field is not dropped by NON_NULL when unset. - .NET: tighten ExpConfigEntry.Parameters value type from JsonNode? to JsonValue? to constrain values to JSON scalars. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 43d7a02b-08ff-4e7d-a8c0-afa6dfbe94b0 --- dotnet/src/Types.cs | 6 ++-- go/client_test.go | 36 +++++++++++++++++++ go/types.go | 29 +++++++++++++++ .../rpc/CopilotExpAssignmentResponse.java | 4 +-- 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index c2ac26e270..78aec40597 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2836,11 +2836,11 @@ public sealed class ExpConfigEntry public string Id { get; set; } = string.Empty; ///

- /// Parameter values keyed by parameter name. Each value is a string, number, - /// boolean, or null. + /// Parameter values keyed by parameter name. Each value is a scalar string, + /// number, boolean, or null. /// [JsonPropertyName("Parameters")] - public IDictionary Parameters { get; set; } = new Dictionary(); + public IDictionary Parameters { get; set; } = new Dictionary(); } /// diff --git a/go/client_test.go b/go/client_test.go index ab977fcdb5..3e4675d0b3 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -3225,6 +3225,42 @@ func TestCreateSessionRequest_ExpAssignments(t *testing.T) { }) } +func TestCopilotExpAssignmentResponse_MarshalNormalizesNilCollections(t *testing.T) { + // A response left with zero-value collections must still serialize the + // required fields as JSON arrays/objects, not null, so the runtime does not + // treat the payload as malformed. + data, err := json.Marshal(&CopilotExpAssignmentResponse{AssignmentContext: "ctx"}) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + for _, tc := range []struct{ key, want string }{ + {"Features", "[]"}, + {"Flights", "{}"}, + {"Configs", "[]"}, + {"AssignmentContext", `"ctx"`}, + } { + if got := string(m[tc.key]); got != tc.want { + t.Errorf("Expected %s to serialize as %s, got %s", tc.key, tc.want, got) + } + } + + // A nil Parameters map on an entry must likewise serialize as {}. + entryData, err := json.Marshal(ExpConfigEntry{ID: "cfg"}) + if err != nil { + t.Fatalf("Failed to marshal entry: %v", err) + } + if err := json.Unmarshal(entryData, &m); err != nil { + t.Fatalf("Failed to unmarshal entry: %v", err) + } + if got := string(m["Parameters"]); got != "{}" { + t.Errorf("Expected Parameters to serialize as {}, got %s", got) + } +} + func TestResumeSessionRequest_ExpAssignments(t *testing.T) { assignments := &CopilotExpAssignmentResponse{ Features: []string{"copilot_exp_flag"}, diff --git a/go/types.go b/go/types.go index c942d9a94d..27a4b218d4 100644 --- a/go/types.go +++ b/go/types.go @@ -1064,6 +1064,35 @@ type CopilotExpAssignmentResponse struct { AssignmentContext string `json:"AssignmentContext"` } +// MarshalJSON normalizes the required collection fields so a zero-value +// response serializes them as JSON arrays/objects rather than null, which the +// runtime can otherwise treat as a malformed assignment payload and drop. +func (r CopilotExpAssignmentResponse) MarshalJSON() ([]byte, error) { + type wire CopilotExpAssignmentResponse + w := wire(r) + if w.Features == nil { + w.Features = []string{} + } + if w.Flights == nil { + w.Flights = map[string]string{} + } + if w.Configs == nil { + w.Configs = []ExpConfigEntry{} + } + return json.Marshal(w) +} + +// MarshalJSON normalizes the required Parameters map so an entry serializes it +// as a JSON object rather than null. +func (e ExpConfigEntry) MarshalJSON() ([]byte, error) { + type wire ExpConfigEntry + w := wire(e) + if w.Parameters == nil { + w.Parameters = map[string]ExpFlagValue{} + } + return json.Marshal(w) +} + // SessionConfig configures a new session type SessionConfig struct { // SessionID is an optional custom session ID diff --git a/java/src/main/java/com/github/copilot/rpc/CopilotExpAssignmentResponse.java b/java/src/main/java/com/github/copilot/rpc/CopilotExpAssignmentResponse.java index df280bd2dd..c25497be97 100644 --- a/java/src/main/java/com/github/copilot/rpc/CopilotExpAssignmentResponse.java +++ b/java/src/main/java/com/github/copilot/rpc/CopilotExpAssignmentResponse.java @@ -44,7 +44,7 @@ public class CopilotExpAssignmentResponse { private String impressionId; @JsonProperty("AssignmentContext") - private String assignmentContext; + private String assignmentContext = ""; /** * Gets the enabled feature names. @@ -175,7 +175,7 @@ public CopilotExpAssignmentResponse setImpressionId(String impressionId) { /** * Gets the assignment context string forwarded to CAPI and telemetry. * - * @return the assignment context, or {@code null} if not set + * @return the assignment context (empty string when unset) */ public String getAssignmentContext() { return assignmentContext; From 69ab40f2767718d948644debb991c2f82d79010e Mon Sep 17 00:00:00 2001 From: Devraj Mehta Date: Sun, 19 Jul 2026 11:38:06 -0400 Subject: [PATCH 3/5] Default ExpConfigEntry.Id to "" in Java The Id field is required by the wire contract, but defaulting to null let class-level NON_NULL drop the key for a zero-value entry. Default it to "" so the required key is always emitted, matching the Go and .NET defaults. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 43d7a02b-08ff-4e7d-a8c0-afa6dfbe94b0 --- java/src/main/java/com/github/copilot/rpc/ExpConfigEntry.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/java/src/main/java/com/github/copilot/rpc/ExpConfigEntry.java b/java/src/main/java/com/github/copilot/rpc/ExpConfigEntry.java index c49b498f15..7905b2c068 100644 --- a/java/src/main/java/com/github/copilot/rpc/ExpConfigEntry.java +++ b/java/src/main/java/com/github/copilot/rpc/ExpConfigEntry.java @@ -21,7 +21,7 @@ public class ExpConfigEntry { @JsonProperty("Id") - private String id; + private String id = ""; @JsonProperty("Parameters") private Map parameters = new LinkedHashMap<>(); @@ -29,7 +29,7 @@ public class ExpConfigEntry { /** * Gets the identifier of this configuration entry. * - * @return the entry identifier, or {@code null} if not set + * @return the entry identifier (empty string when unset) */ public String getId() { return id; From 05849167ff61d8b77bdf1a0ae20f19f6fb2a8c20 Mon Sep 17 00:00:00 2001 From: Devraj Mehta Date: Sun, 19 Jul 2026 11:45:36 -0400 Subject: [PATCH 4/5] Fix stale "opaque JSON" comment in Python exp wiring The expAssignments path now serializes a concrete CopilotExpAssignmentResponse, so drop the outdated "opaque JSON" wording from the adjacent comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 43d7a02b-08ff-4e7d-a8c0-afa6dfbe94b0 --- python/copilot/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/copilot/client.py b/python/copilot/client.py index 392d2fd5cc..84e0ed16a0 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2316,7 +2316,7 @@ async def create_session( if cloud is not None: payload["cloud"] = _cloud_session_options_to_dict(cloud) - # Add ExP assignment data if provided (opaque JSON, trusted integrator) + # Add ExP assignment data if provided (trusted integrator) if exp_assignments is not None: payload["expAssignments"] = _exp_assignment_response_to_dict(exp_assignments) @@ -3013,7 +3013,7 @@ async def resume_session( if remote_session is not None: payload["remoteSession"] = remote_session.value - # Add ExP assignment data if provided (opaque JSON, trusted integrator) + # Add ExP assignment data if provided (trusted integrator) if exp_assignments is not None: payload["expAssignments"] = _exp_assignment_response_to_dict(exp_assignments) From 5bda4015ee8cdd96ca092a3efd7db381cb3d0f9a Mon Sep 17 00:00:00 2001 From: Devraj Mehta Date: Sun, 19 Jul 2026 11:52:35 -0400 Subject: [PATCH 5/5] Fix nightly rustfmt import grouping in types.rs tests Move `use std::collections::HashMap;` into the std import block so `cargo fmt --check` with the nightly group_imports config passes in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 43d7a02b-08ff-4e7d-a8c0-afa6dfbe94b0 --- rust/src/types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/src/types.rs b/rust/src/types.rs index a447f3624f..2614ecf39a 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -5469,6 +5469,7 @@ impl Default for ExitPlanModeData { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::path::PathBuf; use serde_json::json; @@ -5485,7 +5486,6 @@ mod tests { ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; - use std::collections::HashMap; #[test] fn tool_builder_composes() {