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
4 changes: 2 additions & 2 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2758,7 +2758,7 @@ internal record CreateSessionRequest(
IList<NamedProviderConfig>? Providers = null,
IList<ProviderModelConfig>? 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
Expand Down Expand Up @@ -2864,7 +2864,7 @@ internal record ResumeSessionRequest(
IList<NamedProviderConfig>? Providers = null,
IList<ProviderModelConfig>? 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
Expand Down
62 changes: 61 additions & 1 deletion dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2825,6 +2825,64 @@ public struct SetModelOptions
public ModelCapabilitiesOverride? ModelCapabilities { get; set; }
}

/// <summary>
/// A single configuration entry in a <see cref="CopilotExpAssignmentResponse"/>.
/// Each entry carries an identifier and a bag of typed parameter values.
/// </summary>
public sealed class ExpConfigEntry
{
/// <summary>Identifier of the configuration entry.</summary>
[JsonPropertyName("Id")]
public string Id { get; set; } = string.Empty;

/// <summary>
/// Parameter values keyed by parameter name. Each value is a scalar string,
/// number, boolean, or <c>null</c>.
/// </summary>
[JsonPropertyName("Parameters")]
public IDictionary<string, JsonValue?> Parameters { get; set; } = new Dictionary<string, JsonValue?>();
}

/// <summary>
/// ExP ("flight") assignment data, in the same JSON shape the Copilot CLI
/// fetches from the experimentation service. Property names serialize as
/// PascalCase (<c>Features</c>, <c>Flights</c>, ...) to match the on-the-wire
/// contract consumed by the runtime.
/// </summary>
public sealed class CopilotExpAssignmentResponse
{
/// <summary>Enabled feature names.</summary>
[JsonPropertyName("Features")]
public IList<string> Features { get; set; } = new List<string>();

/// <summary>Assigned flights keyed by flight name.</summary>
[JsonPropertyName("Flights")]
public IDictionary<string, string> Flights { get; set; } = new Dictionary<string, string>();

/// <summary>Configuration entries carrying typed parameter values.</summary>
[JsonPropertyName("Configs")]
public IList<ExpConfigEntry> Configs { get; set; } = new List<ExpConfigEntry>();

/// <summary>Opaque parameter-group payload passed through untouched. Optional.</summary>
[JsonPropertyName("ParameterGroups")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonNode? ParameterGroups { get; set; }

/// <summary>Version of the flighting configuration. Optional.</summary>
[JsonPropertyName("FlightingVersion")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? FlightingVersion { get; set; }

/// <summary>Impression identifier for the assignment. Optional.</summary>
[JsonPropertyName("ImpressionId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ImpressionId { get; set; }

/// <summary>Assignment context string forwarded to CAPI and telemetry.</summary>
[JsonPropertyName("AssignmentContext")]
public string AssignmentContext { get; set; } = string.Empty;
}

/// <summary>
/// Shared configuration properties for creating or resuming a Copilot session.
/// Use <see cref="SessionConfig"/> when creating a new session, or
Expand Down Expand Up @@ -3331,7 +3389,7 @@ protected SessionConfigBase(SessionConfigBase? other)
/// completion. It is not part of the broadly advertised public surface.
/// </remarks>
[EditorBrowsable(EditorBrowsableState.Never)]
public JsonElement? ExpAssignments { get; set; }
public CopilotExpAssignmentResponse? ExpAssignments { get; set; }

/// <summary>
/// Opt-in: when <c>true</c>, the runtime self-fetches enterprise managed
Expand Down Expand Up @@ -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))]
Expand Down
39 changes: 21 additions & 18 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*--------------------------------------------------------------------------------------------*/

using Xunit;
using System.Collections.Generic;
using System.Text.Json;
#if !NET8_0_OR_GREATER
using System.Runtime.Serialization;
Expand Down Expand Up @@ -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<ExpConfigEntry> { 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<ExpConfigEntry> { new() { Id = "exp-resume" } },
}));

var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options);
using var resumeDocument = JsonDocument.Parse(resumeJson);
Expand Down Expand Up @@ -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<ExpConfigEntry> { 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<ExpConfigEntry> { 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]
Expand Down
56 changes: 50 additions & 6 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -3221,10 +3225,50 @@ 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 := 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) {
Expand Down
8 changes: 4 additions & 4 deletions go/internal/e2e/client_options_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"])
}
})
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"])
}
})
Expand Down
Loading
Loading