Skip to content
Draft
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
12 changes: 8 additions & 4 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,8 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
ExtensionInfo: config.ExtensionInfo,
Providers: config.Providers,
Models: config.Models,
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence);
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
ExpAssignments: config.ExpAssignments);

var rpcTimestamp = Stopwatch.GetTimestamp();

Expand Down Expand Up @@ -1213,7 +1214,8 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
OpenCanvases: config.OpenCanvases,
Providers: config.Providers,
Models: config.Models,
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence);
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
ExpAssignments: config.ExpAssignments);

var rpcTimestamp = Stopwatch.GetTimestamp();
var response = await InvokeRpcAsync<ResumeSessionResponse>(
Expand Down Expand Up @@ -2411,7 +2413,8 @@ internal record CreateSessionRequest(
ExtensionInfo? ExtensionInfo = null,
IList<NamedProviderConfig>? Providers = null,
IList<ProviderModelConfig>? Models = null,
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null);
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null,
[property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null);
#pragma warning restore GHCP001

internal record ToolDefinition(
Expand Down Expand Up @@ -2506,7 +2509,8 @@ internal record ResumeSessionRequest(
IList<OpenCanvasInstance>? OpenCanvases = null,
IList<NamedProviderConfig>? Providers = null,
IList<ProviderModelConfig>? Models = null,
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null);
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null,
[property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null);
#pragma warning restore GHCP001

internal record ResumeSessionResponse(
Expand Down
17 changes: 17 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3043,6 +3043,23 @@ protected SessionConfigBase(SessionConfigBase? other)
/// </summary>
public RemoteSessionMode? RemoteSession { get; set; }

/// <summary>
/// ExP assignment ("flight") data injected by a trusted integrator, in the
/// same JSON shape the Copilot CLI fetches from the experimentation service
/// (<c>CopilotExpAssignmentResponse</c>). 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 unset, the session does
/// not block on ExP. Intended for out-of-process integrators that fetch ExP
/// data themselves; malformed payloads are dropped by the runtime (fail-open).
/// Serialized on the wire as <c>expAssignments</c>.
/// </summary>
/// <remarks>
/// This is an internal/trusted-integrator option and is hidden from editor
/// completion. It is not part of the broadly advertised public surface.
/// </remarks>
[EditorBrowsable(EditorBrowsableState.Never)]
public JsonElement? ExpAssignments { get; set; }

#pragma warning disable GHCP001
/// <summary>
/// Canvas declarations advertised by this connection. The runtime forwards
Expand Down
54 changes: 54 additions & 0 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,60 @@ public void SessionRequests_OmitMemory_WhenUnset()
Assert.False(resumeDocument.RootElement.TryGetProperty("memory", out _));
}

[Fact]
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()));

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()));

var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options);
using var resumeDocument = JsonDocument.Parse(resumeJson);
var resumeRoot = resumeDocument.RootElement;
Assert.Equal("exp-resume", resumeRoot.GetProperty("expAssignments").GetProperty("Configs")[0].GetProperty("Id").GetString());
}

[Fact]
public void SessionRequests_OmitExpAssignments_WhenUnset()
{
var options = GetSerializerOptions();

var createRequestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest");
var createRequest = CreateInternalRequest(
createRequestType,
("SessionId", "session-id"));

var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options);
using var createDocument = JsonDocument.Parse(createJson);
Assert.False(createDocument.RootElement.TryGetProperty("expAssignments", out _));

var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest");
var resumeRequest = CreateInternalRequest(
resumeRequestType,
("SessionId", "session-id"));

var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options);
using var resumeDocument = JsonDocument.Parse(resumeJson);
Assert.False(resumeDocument.RootElement.TryGetProperty("expAssignments", out _));
}

[Fact]
public void CreateSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptions()
{
Expand Down
2 changes: 2 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
req.RequestCanvasRenderer = config.RequestCanvasRenderer
req.RequestExtensions = config.RequestExtensions
req.ExtensionSDKPath = config.ExtensionSDKPath
req.ExpAssignments = config.ExpAssignments

if len(config.Commands) > 0 {
cmds := make([]wireCommand, 0, len(config.Commands))
Expand Down Expand Up @@ -1051,6 +1052,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
req.RequestCanvasRenderer = config.RequestCanvasRenderer
req.RequestExtensions = config.RequestExtensions
req.ExtensionSDKPath = config.ExtensionSDKPath
req.ExpAssignments = config.ExpAssignments
if config.OnPermissionRequest != nil {
req.RequestPermission = Bool(true)
}
Expand Down
82 changes: 82 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2220,3 +2220,85 @@ func TestStartCLIServer_StderrFieldSet(t *testing.T) {
t.Error("expected Stderr to be *truncbuffer.TruncBuffer after assignment")
}
}

func TestCreateSessionRequest_ExpAssignments(t *testing.T) {
assignments := map[string]any{
"Parameters": map[string]any{"copilot_exp_flag": "treatment"},
"AssignmentContext": "ctx-123",
}

t.Run("includes expAssignments in JSON when set", func(t *testing.T) {
req := createSessionRequest{ExpAssignments: assignments}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
got, ok := m["expAssignments"].(map[string]any)
if !ok {
t.Fatalf("Expected expAssignments to be an object, got %v", m["expAssignments"])
}
if got["AssignmentContext"] != "ctx-123" {
t.Errorf("Expected AssignmentContext 'ctx-123', got %v", got["AssignmentContext"])
}
})

t.Run("omits expAssignments from JSON when nil", func(t *testing.T) {
req := createSessionRequest{}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if _, ok := m["expAssignments"]; ok {
t.Error("Expected expAssignments to be omitted when nil")
}
})
}

func TestResumeSessionRequest_ExpAssignments(t *testing.T) {
assignments := map[string]any{
"Parameters": map[string]any{"copilot_exp_flag": "treatment"},
"AssignmentContext": "ctx-456",
}

t.Run("includes expAssignments in JSON when set", func(t *testing.T) {
req := resumeSessionRequest{SessionID: "s1", ExpAssignments: assignments}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
got, ok := m["expAssignments"].(map[string]any)
if !ok {
t.Fatalf("Expected expAssignments to be an object, got %v", m["expAssignments"])
}
if got["AssignmentContext"] != "ctx-456" {
t.Errorf("Expected AssignmentContext 'ctx-456', got %v", got["AssignmentContext"])
}
})

t.Run("omits expAssignments from JSON when nil", func(t *testing.T) {
req := resumeSessionRequest{SessionID: "s1"}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if _, ok := m["expAssignments"]; ok {
t.Error("Expected expAssignments to be omitted when nil")
}
})
}
22 changes: 22 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,18 @@ type SessionConfig struct {
CanvasHandler CanvasHandler `json:"-"`
// ExtensionInfo identifies the stable extension providing this session's canvases.
ExtensionInfo *ExtensionInfo
// ExpAssignments injects ExP assignment ("flight") data for this session,
// in the same JSON shape the Copilot CLI fetches from the experimentation
// service (CopilotExpAssignmentResponse). When supplied, 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. Malformed payloads are dropped by the runtime
// (fail-open).
//
// 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
}

// ToolDefer controls whether a tool may be deferred (loaded lazily via tool
Expand Down Expand Up @@ -1529,6 +1541,14 @@ type ResumeSessionConfig struct {
CanvasHandler CanvasHandler `json:"-"`
// ExtensionInfo identifies the stable extension providing this session's canvases.
ExtensionInfo *ExtensionInfo
// ExpAssignments injects ExP assignment ("flight") data on resume. See
// SessionConfig.ExpAssignments. Re-supply on resume so the runtime
// re-applies the assignments after a CLI process restart.
//
// 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
}
type ProviderConfig struct {
// Type is the provider type: "openai", "azure", or "anthropic". Defaults to "openai".
Expand Down Expand Up @@ -1879,6 +1899,7 @@ type createSessionRequest struct {
RequestExtensions *bool `json:"requestExtensions,omitempty"`
ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"`
ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"`
ExpAssignments any `json:"expAssignments,omitempty"`
Traceparent string `json:"traceparent,omitempty"`
Tracestate string `json:"tracestate,omitempty"`
}
Expand Down Expand Up @@ -1963,6 +1984,7 @@ type resumeSessionRequest struct {
RequestExtensions *bool `json:"requestExtensions,omitempty"`
ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"`
ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"`
ExpAssignments any `json:"expAssignments,omitempty"`
Traceparent string `json:"traceparent,omitempty"`
Tracestate string `json:"tracestate,omitempty"`
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess
request.setGitHubToken(config.getGitHubToken());
request.setRemoteSession(config.getRemoteSession());
request.setCloud(config.getCloud());
request.setExpAssignments(config.getExpAssignments());

return request;
}
Expand Down Expand Up @@ -294,6 +295,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo
}
request.setGitHubToken(config.getGitHubToken());
request.setRemoteSession(config.getRemoteSession());
request.setExpAssignments(config.getExpAssignments());

return request;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;

import com.github.copilot.CopilotExperimental;

Expand Down Expand Up @@ -195,6 +196,9 @@ public final class CreateSessionRequest {
@JsonProperty("cloud")
private CloudSessionOptions cloud;

@JsonProperty("expAssignments")
private JsonNode expAssignments;

/** Gets the model name. @return the model */
public String getModel() {
return model;
Expand Down Expand Up @@ -884,4 +888,16 @@ public CloudSessionOptions getCloud() {
public void setCloud(CloudSessionOptions cloud) {
this.cloud = cloud;
}

/** Gets the ExP assignment data. @return the ExP assignment data */
public JsonNode getExpAssignments() {
return expAssignments;
}

/**
* Sets the ExP assignment data. @param expAssignments the ExP assignment data
*/
public void setExpAssignments(JsonNode expAssignments) {
this.expAssignments = expAssignments;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

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;
Expand Down Expand Up @@ -95,6 +96,7 @@ public class ResumeSessionConfig {
private boolean enableMcpApps;
private String gitHubToken;
private String remoteSession;
private JsonNode expAssignments;

/**
* Gets the AI model to use.
Expand Down Expand Up @@ -1609,6 +1611,30 @@ public ResumeSessionConfig setRemoteSession(String remoteSession) {
return this;
}

/**
* Gets the ExP assignment ("flight") data injected by a trusted integrator.
*
* @return the ExP assignment data, or {@code null} if not set
*/
public JsonNode getExpAssignments() {
return expAssignments;
}

/**
* Sets ExP assignment ("flight") data injected by a trusted integrator.
* <p>
* See {@link SessionConfig#setExpAssignments(JsonNode)} for details. The
* runtime supports injecting ExP assignments on resume as well as create.
*
* @param expAssignments
* the opaque ExP assignment data
* @return this config for method chaining
*/
public ResumeSessionConfig setExpAssignments(JsonNode expAssignments) {
this.expAssignments = expAssignments;
return this;
}

/**
* Creates a shallow clone of this {@code ResumeSessionConfig} instance.
* <p>
Expand Down Expand Up @@ -1676,6 +1702,7 @@ public ResumeSessionConfig clone() {
copy.enableMcpApps = this.enableMcpApps;
copy.gitHubToken = this.gitHubToken;
copy.remoteSession = this.remoteSession;
copy.expAssignments = this.expAssignments;
return copy;
}
}
Loading
Loading