Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 string, number,
/// boolean, or <c>null</c>.
/// </summary>
[JsonPropertyName("Parameters")]
public IDictionary<string, JsonNode?> Parameters { get; set; } = new Dictionary<string, JsonNode?>();
Comment thread
devm33 marked this conversation as resolved.
Outdated
}

/// <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
20 changes: 14 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 @@ -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) {
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
46 changes: 42 additions & 4 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
devm33 marked this conversation as resolved.

// 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"`
Comment thread
devm33 marked this conversation as resolved.
}

// 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"`
Comment thread
devm33 marked this conversation as resolved.
// 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down
Loading
Loading