From ff50921f9acf0afe43c6a0c2ea1dd7907fc98afe Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 22 Jun 2026 13:22:46 -0700 Subject: [PATCH 1/2] Add exp_assignments injection to session create/resume config Expose ExP assignment ("flight") data on the SDK's session-open and session-resume paths so an out-of-process integrator can inject the same CopilotExpAssignmentResponse payload the CLI fetches itself. The runtime already accepts expAssignments on the wire, but the hand-written SessionCreateWire / SessionResumeWire structs (and their public configs) did not carry it. - SessionConfig / ResumeSessionConfig: add doc-hidden exp_assignments field (serde_json::Value) plus a doc-hidden with_exp_assignments builder - SessionCreateWire / SessionResumeWire: add exp_assignments, serialized as camelCase expAssignments and omitted when None - Forward the field through both into_wire paths - Unit tests asserting expAssignments is emitted on create and resume and omitted when unset Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/types.rs | 86 +++++++++++++++++++++++++++++++++++++++++++++-- rust/src/wire.rs | 4 +++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/rust/src/types.rs b/rust/src/types.rs index 5c1c0ddf3..9d7d6f3ca 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1587,6 +1587,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. @@ -1714,6 +1722,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(|_| ""), @@ -1810,6 +1819,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, @@ -1955,6 +1965,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 { @@ -2472,9 +2483,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 @@ -2617,6 +2639,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). @@ -2737,6 +2765,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(|_| ""), @@ -2877,6 +2906,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, }; @@ -2956,6 +2986,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, @@ -3455,6 +3486,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. @@ -4867,6 +4907,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 cc9968100..d9627ca00 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -153,6 +153,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. @@ -269,4 +271,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, } From a5828eda342535cd08e8d7816cb1f6eb46ee454f Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 22 Jun 2026 15:20:18 -0700 Subject: [PATCH 2/2] Expose exp_assignments injection across Node, Python, Go, .NET, Java SDKs Mirror the Rust SDK change in the remaining five SDKs so out-of-process integrators can inject ExP ("flight") assignment data into session create and resume. Adds an internal/trusted-integrator config field that forwards to the wire key `expAssignments` (omitted when unset), in the opaque JSON shape of `CopilotExpAssignmentResponse`: - Node: `expAssignments?: Record` on `SessionConfigBase` (`@internal`), forwarded in the inline session.create/session.resume payloads in client.ts. - Python: `exp_assignments: dict[str, Any] | None = None` kwarg on `create_session`/`resume_session`, mapped to `payload["expAssignments"]`. - Go: `ExpAssignments any` on `SessionConfig`/`ResumeSessionConfig` (documented Internal:), forwarded into the create/resume wire structs with `json:"expAssignments,omitempty"`. - .NET: `JsonElement? ExpAssignments` on `SessionConfigBase` (`[EditorBrowsable(Never)]`), wired through the internal CreateSessionRequest/ResumeSessionRequest records. - Java: `JsonNode expAssignments` field + fluent setter/getter on SessionConfig/ResumeSessionConfig, mapped through CreateSessionRequest/ResumeSessionRequest in SessionRequestBuilder. Each language gains create+resume serialization tests asserting the field serializes to `expAssignments` when set and is omitted when unset. Part of github/github-app epic #7452; mirrors the runtime contract added in github/copilot-agent-runtime#9955. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 12 ++- dotnet/src/Types.cs | 17 ++++ dotnet/test/Unit/SerializationTests.cs | 54 ++++++++++++ go/client.go | 2 + go/client_test.go | 82 +++++++++++++++++++ go/types.go | 22 +++++ .../github/copilot/SessionRequestBuilder.java | 2 + .../copilot/rpc/CreateSessionRequest.java | 16 ++++ .../copilot/rpc/ResumeSessionConfig.java | 27 ++++++ .../copilot/rpc/ResumeSessionRequest.java | 16 ++++ .../com/github/copilot/rpc/SessionConfig.java | 35 ++++++++ .../copilot/SessionRequestBuilderTest.java | 42 ++++++++++ nodejs/src/client.ts | 2 + nodejs/src/types.ts | 14 ++++ nodejs/test/client.test.ts | 63 ++++++++++++++ python/copilot/client.py | 32 ++++++++ python/test_client.py | 69 ++++++++++++++++ 17 files changed, 503 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 5a5d34dcd..0f59b010b 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1011,7 +1011,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(); @@ -1211,7 +1212,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( @@ -2408,7 +2410,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( @@ -2502,7 +2505,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 d7b326afb..b00c3bb47 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3007,6 +3007,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 f074a4d2f..40e68573c 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -369,6 +369,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 af9044ad9..aacf3c4c2 100644 --- a/go/client.go +++ b/go/client.go @@ -710,6 +710,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)) @@ -1049,6 +1050,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 a3051f881..3e1cdb503 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -2068,3 +2068,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 ba83c6b6d..f67b7539b 100644 --- a/go/types.go +++ b/go/types.go @@ -1132,6 +1132,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 @@ -1525,6 +1537,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". @@ -1854,6 +1874,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"` } @@ -1937,6 +1958,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 66d3e4344..b92f2b0f4 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -176,6 +176,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; } @@ -292,6 +293,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 42a431f49..8a006a70c 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; @@ -193,6 +194,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; @@ -872,4 +876,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 fa900aceb..5206eb00e 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; @@ -94,6 +95,7 @@ public class ResumeSessionConfig { private boolean enableMcpApps; private String gitHubToken; private String remoteSession; + private JsonNode expAssignments; /** * Gets the AI model to use. @@ -1582,6 +1584,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. *

@@ -1648,6 +1674,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 2067a291c..688e75a50 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; @@ -195,6 +196,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; @@ -887,4 +891,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 ef483c410..8eeedae98 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; @@ -95,6 +96,7 @@ public class SessionConfig { private String gitHubToken; private String remoteSession; private CloudSessionOptions cloud; + private JsonNode expAssignments; /** * Gets the custom session ID. @@ -1702,6 +1704,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. *

@@ -1773,6 +1807,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 a6efb061a..528bae001 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1290,6 +1290,7 @@ export class CopilotClient { gitHubToken: config.gitHubToken, remoteSession: config.remoteSession, cloud: config.cloud, + expAssignments: config.expAssignments, }); const { @@ -1478,6 +1479,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 f198a88b3..6638da0bf 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2078,6 +2078,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 42c0ff18e..0218f5ee4 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 pluginDirectories and largeOutput 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 2c407149c..518091969 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1677,6 +1677,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. @@ -1779,6 +1780,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. @@ -1906,6 +1918,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 @@ -2270,6 +2286,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. @@ -2373,6 +2390,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. @@ -2511,6 +2539,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 6af4450de..50e134762 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -356,6 +356,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):