Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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