diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index eaf9859bc..2507680b0 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1012,7 +1012,8 @@ public async Task 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(); @@ -1213,7 +1214,8 @@ public async Task 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( @@ -2411,7 +2413,8 @@ internal record CreateSessionRequest( ExtensionInfo? ExtensionInfo = null, IList? Providers = null, IList? Models = null, - OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null); + OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, + [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null); #pragma warning restore GHCP001 internal record ToolDefinition( @@ -2506,7 +2509,8 @@ internal record ResumeSessionRequest( IList? OpenCanvases = null, IList? Providers = null, IList? Models = null, - OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null); + OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null, + [property: JsonPropertyName("expAssignments")] JsonElement? ExpAssignments = null); #pragma warning restore GHCP001 internal record ResumeSessionResponse( diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 06878a727..96caa76a0 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3043,6 +3043,23 @@ protected SessionConfigBase(SessionConfigBase? other) /// public RemoteSessionMode? RemoteSession { get; set; } + /// + /// ExP assignment ("flight") data injected by a trusted integrator, in the + /// same JSON shape the Copilot CLI fetches from the experimentation service + /// (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 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 expAssignments. + /// + /// + /// This is an internal/trusted-integrator option and is hidden from editor + /// completion. It is not part of the broadly advertised public surface. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public JsonElement? ExpAssignments { get; set; } + #pragma warning disable GHCP001 /// /// Canvas declarations advertised by this connection. The runtime forwards diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index baf71c299..af3ed0676 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -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() { diff --git a/go/client.go b/go/client.go index a824648b4..91fa1868a 100644 --- a/go/client.go +++ b/go/client.go @@ -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)) @@ -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) } diff --git a/go/client_test.go b/go/client_test.go index 383ee315d..d59c71c6f 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -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") + } + }) +} diff --git a/go/types.go b/go/types.go index 0219ce3cd..da162da2a 100644 --- a/go/types.go +++ b/go/types.go @@ -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 @@ -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". @@ -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"` } @@ -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"` } diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index c26548a2f..072cf480d 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -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; } @@ -294,6 +295,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo } request.setGitHubToken(config.getGitHubToken()); request.setRemoteSession(config.getRemoteSession()); + request.setExpAssignments(config.getExpAssignments()); return request; } 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 773e97ce7..8fc966c6f 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -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; @@ -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; @@ -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; + } } 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 df600b0af..e3e79eab0 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -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; @@ -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. @@ -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. + *

+ * 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. *

@@ -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; } } 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 490fbd618..2b25875d7 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -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; @@ -197,6 +198,9 @@ public final class ResumeSessionRequest { @JsonProperty("remoteSession") private String remoteSession; + @JsonProperty("expAssignments") + private JsonNode expAssignments; + /** Gets the session ID. @return the session ID */ public String getSessionId() { return sessionId; @@ -899,4 +903,16 @@ public String getRemoteSession() { public void setRemoteSession(String remoteSession) { this.remoteSession = remoteSession; } + + /** 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; + } } 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 5567d32ac..38b357e7e 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -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; @@ -96,6 +97,7 @@ public class SessionConfig { private String gitHubToken; private String remoteSession; private CloudSessionOptions cloud; + private JsonNode expAssignments; /** * Gets the custom session ID. @@ -1729,6 +1731,38 @@ public SessionConfig setCloud(CloudSessionOptions cloud) { 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. + *

+ * The value is opaque JSON in the same shape the Copilot CLI fetches from the + * experimentation service ({@code 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 + * integrators that fetch ExP data themselves; malformed payloads are dropped by + * the runtime (fail-open). Serialized on the wire as {@code expAssignments}. + *

+ * This is an internal/trusted-integrator option, not part of the broadly + * advertised public surface. + * + * @param expAssignments + * the opaque ExP assignment data + * @return this config instance for method chaining + */ + public SessionConfig setExpAssignments(JsonNode expAssignments) { + this.expAssignments = expAssignments; + return this; + } + /** * Creates a shallow clone of this {@code SessionConfig} instance. *

@@ -1801,6 +1835,7 @@ public SessionConfig clone() { copy.gitHubToken = this.gitHubToken; copy.remoteSession = this.remoteSession; copy.cloud = this.cloud; + copy.expAssignments = this.expAssignments; return copy; } } diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 29b7884d9..3321a8826 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.JsonNode; import com.github.copilot.rpc.AutoModeSwitchResponse; import com.github.copilot.rpc.CloudSessionOptions; import com.github.copilot.rpc.CloudSessionRepository; @@ -863,4 +864,45 @@ void testCloudSessionOptionsSerializesCorrectly() throws Exception { assertTrue(json.contains("\"name\":\"widgets\"")); assertTrue(json.contains("\"branch\":\"feature-1\"")); } + + // ========================================================================= + // ExP assignment injection wiring + // ========================================================================= + + @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 createConfig = new SessionConfig().setExpAssignments(createAssignments); + CreateSessionRequest createRequest = SessionRequestBuilder.buildCreateRequest(createConfig); + assertEquals(createAssignments, createRequest.getExpAssignments()); + var createJson = mapper.writeValueAsString(createRequest); + assertTrue(createJson.contains("\"expAssignments\"")); + assertTrue(createJson.contains("\"Id\":\"exp-create\"")); + + var resumeConfig = new ResumeSessionConfig().setExpAssignments(resumeAssignments); + ResumeSessionRequest resumeRequest = SessionRequestBuilder.buildResumeRequest("session-1", resumeConfig); + assertEquals(resumeAssignments, resumeRequest.getExpAssignments()); + var resumeJson = mapper.writeValueAsString(resumeRequest); + assertTrue(resumeJson.contains("\"expAssignments\"")); + assertTrue(resumeJson.contains("\"Id\":\"exp-resume\"")); + } + + @Test + void testBuildRequestsOmitExpAssignmentsWhenUnset() throws Exception { + var mapper = JsonRpcClient.getObjectMapper(); + + CreateSessionRequest createRequest = SessionRequestBuilder.buildCreateRequest(new SessionConfig()); + assertNull(createRequest.getExpAssignments()); + var createJson = mapper.writeValueAsString(createRequest); + assertFalse(createJson.contains("\"expAssignments\""), "expAssignments should be omitted when null"); + + ResumeSessionRequest resumeRequest = SessionRequestBuilder.buildResumeRequest("session-1", + new ResumeSessionConfig()); + assertNull(resumeRequest.getExpAssignments()); + var resumeJson = mapper.writeValueAsString(resumeRequest); + assertFalse(resumeJson.contains("\"expAssignments\""), "expAssignments should be omitted when null"); + } } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 473479ac1..5fce47087 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1291,6 +1291,7 @@ export class CopilotClient { gitHubToken: config.gitHubToken, remoteSession: config.remoteSession, cloud: config.cloud, + expAssignments: config.expAssignments, }); const { @@ -1480,6 +1481,7 @@ export class CopilotClient { gitHubToken: config.gitHubToken, remoteSession: config.remoteSession, openCanvases: config.openCanvases, + expAssignments: config.expAssignments, }); const { workspacePath, capabilities, openCanvases } = response as { diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 452a47517..90f3a1218 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2110,6 +2110,20 @@ export interface SessionConfigBase { * only if {@link CopilotClientOptions.sessionFs} is configured. */ createSessionFsProvider?: (session: CopilotSession) => SessionFsProvider; + + /** + * ExP assignment ("flight") data injected by a trusted integrator, 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. Intended for out-of-process integrators that fetch ExP + * data themselves; malformed payloads are dropped by the runtime + * (fail-open). Applies to both session creation and resume. + * + * @internal + */ + expAssignments?: Record; } /** diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index ef804095f..8034265cf 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -171,6 +171,69 @@ describe("CopilotClient", () => { expect(resumePayload.contextTier).toBe("default"); }); + it("forwards expAssignments in session.create and session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + const assignments = { + Parameters: { copilot_exp_flag: "treatment" }, + AssignmentContext: "ctx-123", + }; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + expAssignments: assignments, + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + expAssignments: assignments, + }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const resumePayload = spy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(createPayload.expAssignments).toEqual(assignments); + expect(resumePayload.expAssignments).toEqual(assignments); + }); + + it("omits expAssignments from session.create and session.resume when unset", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const resumePayload = spy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(createPayload.expAssignments).toBeUndefined(); + expect(resumePayload.expAssignments).toBeUndefined(); + }); + it("forwards capi options in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/python/copilot/client.py b/python/copilot/client.py index 7a650d380..b26d58a14 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1690,6 +1690,7 @@ async def create_session( extension_sdk_path: str | None = None, extension_info: ExtensionInfo | None = None, canvas_handler: CanvasHandler | None = None, + exp_assignments: dict[str, Any] | None = None, ) -> CopilotSession: """ Create a new conversation session with the Copilot CLI. @@ -1802,6 +1803,17 @@ async def create_session( override) is on; otherwise the request is silently dropped. Inspect ``capabilities.ui.mcpApps`` on the create response to detect the drop. + exp_assignments: ExP assignment ("flight") data injected by a + trusted integrator, 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. Intended + for out-of-process integrators that fetch ExP data themselves; + malformed payloads are dropped by the runtime (fail-open). This + is an internal/trusted-integrator option. Sent on the wire as + ``expAssignments``. Returns: A :class:`CopilotSession` instance for the new session. @@ -1929,6 +1941,10 @@ 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) + if exp_assignments is not None: + payload["expAssignments"] = exp_assignments + # Add working directory if provided if working_directory: payload["workingDirectory"] = working_directory @@ -2296,6 +2312,7 @@ async def resume_session( extension_info: ExtensionInfo | None = None, canvas_handler: CanvasHandler | None = None, open_canvases: list[OpenCanvasInstance] | None = None, + exp_assignments: dict[str, Any] | None = None, ) -> CopilotSession: """ Resume an existing conversation session by its ID. @@ -2409,6 +2426,17 @@ async def resume_session( tool calls or permission prompts that were still pending when the session was last suspended. When False (the default), the runtime treats pending work as interrupted on resume. + exp_assignments: ExP assignment ("flight") data injected by a + trusted integrator, 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. Intended + for out-of-process integrators that fetch ExP data themselves; + malformed payloads are dropped by the runtime (fail-open). This + is an internal/trusted-integrator option. Sent on the wire as + ``expAssignments``. Returns: A :class:`CopilotSession` instance for the resumed session. @@ -2549,6 +2577,10 @@ 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) + if exp_assignments is not None: + payload["expAssignments"] = exp_assignments + if working_directory: payload["workingDirectory"] = working_directory if config_directory: diff --git a/python/test_client.py b/python/test_client.py index a3b7e85f0..ca812c53d 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -397,6 +397,75 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_exp_assignments(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + + create_assignments = {"Configs": [{"Id": "exp-create"}]} + resume_assignments = {"Configs": [{"Id": "exp-resume"}]} + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + exp_assignments=create_assignments, + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + exp_assignments=resume_assignments, + ) + + assert captured["session.create"]["expAssignments"] == create_assignments + assert captured["session.resume"]["expAssignments"] == resume_assignments + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_create_and_resume_session_omit_exp_assignments_when_unset(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + ) + + assert "expAssignments" not in captured["session.create"] + assert "expAssignments" not in captured["session.resume"] + finally: + await client.force_stop() + class TestURLParsing: def test_parse_port_only_url(self): diff --git a/rust/src/types.rs b/rust/src/types.rs index 01b18eb52..1a65b55bf 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1644,6 +1644,14 @@ pub struct SessionConfig { /// each command appears as `/name` for the user to invoke and the /// associated [`CommandHandler`] is called when executed. pub commands: Option>, + /// ExP assignment ("flight") data injected by a trusted integrator, 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. + /// When absent, the session does not block on ExP. Set via + /// [`with_exp_assignments`](Self::with_exp_assignments). + #[doc(hidden)] + pub exp_assignments: Option, /// Custom session filesystem provider for this session. Required when /// the [`Client`](crate::Client) was started with /// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs) set. @@ -1772,6 +1780,7 @@ impl std::fmt::Debug for SessionConfig { &self.include_sub_agent_streaming_events, ) .field("commands", &self.commands) + .field("exp_assignments", &self.exp_assignments) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -1869,6 +1878,7 @@ impl Default for SessionConfig { cloud: None, include_sub_agent_streaming_events: None, commands: None, + exp_assignments: None, session_fs_provider: None, permission_handler: None, elicitation_handler: None, @@ -2015,6 +2025,7 @@ impl SessionConfig { cloud: self.cloud, include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, commands: wire_commands, + exp_assignments: self.exp_assignments, }; let runtime = SessionConfigRuntime { @@ -2538,9 +2549,20 @@ impl SessionConfig { self.manage_schedule_enabled = Some(value); self } -} -/// Configuration for resuming an existing session via the `session.resume` RPC. + /// Inject ExP assignment ("flight") data for this session, in the same + /// JSON shape the Copilot CLI fetches from the experimentation service + /// (`CopilotExpAssignmentResponse`). The runtime feeds it into the same + /// feature-flag path as CLI-fetched assignments and stamps it onto + /// telemetry and the CAPI request header. Intended for trusted + /// 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 { + self.exp_assignments = Some(assignments); + self + } +} /// /// See [`SessionConfig`] for the construction patterns (chained `with_*` /// builder vs. direct field assignment for `Option` pass-through) and @@ -2689,6 +2711,12 @@ pub struct ResumeSessionConfig { /// [`SessionConfig::commands`] — commands are not persisted server-side, /// so the resume payload re-supplies the registration. pub commands: Option>, + /// ExP assignment ("flight") data injected on resume. See + /// [`SessionConfig::exp_assignments`]. Re-supply on resume so the runtime + /// re-applies the assignments after a CLI process restart. Set via + /// [`with_exp_assignments`](Self::with_exp_assignments). + #[doc(hidden)] + pub exp_assignments: Option, /// Custom session filesystem provider. Required on resume when the /// [`Client`](crate::Client) was started with /// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs). @@ -2810,6 +2838,7 @@ impl std::fmt::Debug for ResumeSessionConfig { &self.include_sub_agent_streaming_events, ) .field("commands", &self.commands) + .field("exp_assignments", &self.exp_assignments) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -2951,6 +2980,7 @@ impl ResumeSessionConfig { remote_session: self.remote_session, include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, commands: wire_commands, + exp_assignments: self.exp_assignments, suppress_resume_event: self.suppress_resume_event, continue_pending_work: self.continue_pending_work, }; @@ -3031,6 +3061,7 @@ impl ResumeSessionConfig { remote_session: None, include_sub_agent_streaming_events: None, commands: None, + exp_assignments: None, session_fs_provider: None, suppress_resume_event: None, continue_pending_work: None, @@ -3536,6 +3567,15 @@ impl ResumeSessionConfig { self.manage_schedule_enabled = Some(value); self } + + /// Inject ExP assignment ("flight") data on resume. See + /// [`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 { + self.exp_assignments = Some(assignments); + self + } } /// Controls how the system message is constructed. @@ -4948,6 +4988,48 @@ mod tests { assert!(empty_json.get("memory").is_none()); } + #[test] + fn session_config_with_exp_assignments_serializes() { + let assignments = serde_json::json!({ + "Parameters": { "copilot_exp_flag": "treatment" }, + "AssignmentContext": "ctx-123", + }); + let (wire, _runtime) = SessionConfig::default() + .with_exp_assignments(assignments.clone()) + .into_wire(Some(SessionId::from("exp-on"))) + .expect("no duplicate handlers"); + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!(json["expAssignments"], assignments); + + // Unset exp assignments are omitted on the wire. + let (empty_wire, _) = SessionConfig::default() + .into_wire(Some(SessionId::from("exp-unset"))) + .expect("no duplicate handlers"); + let empty_json = serde_json::to_value(&empty_wire).unwrap(); + assert!(empty_json.get("expAssignments").is_none()); + } + + #[test] + fn resume_session_config_with_exp_assignments_serializes() { + let assignments = serde_json::json!({ + "Parameters": { "copilot_exp_flag": "treatment" }, + "AssignmentContext": "ctx-456", + }); + let (wire, _runtime) = ResumeSessionConfig::new(SessionId::from("resume-exp-on")) + .with_exp_assignments(assignments.clone()) + .into_wire() + .expect("no duplicate handlers"); + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!(json["expAssignments"], assignments); + + // Unset exp assignments are omitted on the wire. + let (empty_wire, _) = ResumeSessionConfig::new(SessionId::from("resume-exp-unset")) + .into_wire() + .expect("no duplicate handlers"); + let empty_json = serde_json::to_value(&empty_wire).unwrap(); + assert!(empty_json.get("expAssignments").is_none()); + } + #[test] #[allow(clippy::field_reassign_with_default)] fn session_config_into_wire_serializes_bucket_b_fields() { diff --git a/rust/src/wire.rs b/rust/src/wire.rs index e2a87bc4a..e6dad66d5 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -155,6 +155,8 @@ pub(crate) struct SessionCreateWire { pub include_sub_agent_streaming_events: Option, #[serde(skip_serializing_if = "Option::is_none")] pub commands: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub exp_assignments: Option, } /// The exact JSON shape sent on the `session.resume` JSON-RPC request. @@ -273,4 +275,6 @@ pub(crate) struct SessionResumeWire { pub suppress_resume_event: Option, #[serde(skip_serializing_if = "Option::is_none")] pub continue_pending_work: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exp_assignments: Option, }