diff --git a/rust/README.md b/rust/README.md index 0b5bec1cd..95dea7ed7 100644 --- a/rust/README.md +++ b/rust/README.md @@ -78,6 +78,53 @@ client.stop().await?; With the default `CliProgram::Resolve`, `Client::start()` resolves the CLI in this order: an explicit `CliProgram::Path(path)`, the `COPILOT_CLI_PATH` env var, then the bundled CLI that was embedded at build time. There is no PATH scanning — if you've opted out of bundling (`default-features = false`) you must supply either `CliProgram::Path` or `COPILOT_CLI_PATH`. +### Session capabilities + +The `SessionCapability` enum lets callers enable or disable named runtime features on a **per-session** basis. Capabilities are sent to the runtime as part of the `session.create` / `session.resume` JSON-RPC calls via two wire fields: + +- `enabledCapabilities` -- capabilities to opt the session into (extends the `SDK_CAPABILITIES` baseline) +- `disabledCapabilities` -- capabilities to opt the session out of (disable wins on overlap) + +> **Experimental.** Per-session capability controls are an experimental +> wire-protocol surface and may change or be removed in future SDK or CLI +> releases. + +> **Runtime dependency.** Per-session capability controls require a runtime +> version that supports `enabledCapabilities` and `disabledCapabilities`. +> On older runtimes the fields are silently ignored. + +Use `SessionConfig::with_enable_capability` / `with_disable_capability` (and their plural counterparts): + +```rust,ignore +use github_copilot_sdk::{SessionCapability, SessionConfig}; + +let session = client.create_session( + SessionConfig::default() + .with_enable_capability(SessionCapability::Memory), +).await?; +``` + +On resume, use the same builders on `ResumeSessionConfig`: + +```rust,ignore +use github_copilot_sdk::{ResumeSessionConfig, SessionCapability}; + +let session = client.resume_session( + ResumeSessionConfig::new(session_id) + .with_enable_capability(SessionCapability::Memory), +).await?; +``` + +**Variants.** See the `SessionCapability` enum's own documentation for the +authoritative set of capabilities and their wire names. + +**Disable-wins semantics.** If the same capability appears in both +`enabled_capabilities` and `disabled_capabilities`, disable wins. The runtime +starts from an `SDK_CAPABILITIES` baseline; enabled capabilities extend it and +disabled capabilities remove from it, in that order. `SessionCapability::Unknown` +exists only as a generated deserialization fallback and is rejected if supplied +to the create/resume capability builders. + ### Session Created via `Client::create_session` or `Client::resume_session`. Owns an internal event loop that dispatches CLI callbacks to the focused handler traits you install on `SessionConfig`, and broadcasts session events through `subscribe()`. @@ -714,6 +761,11 @@ gets to be Rust here — cross-SDK parity for these is a post-release conversation, not a release blocker. None of these are deprecated and none of them are scheduled for removal. +- **`SessionCapability` enum** -- typed enum for per-session + capability opt-in / opt-out. Sent via `enabledCapabilities` / + `disabledCapabilities` on the `session.create` and `session.resume` wire + calls. See [Session capabilities](#session-capabilities) above. + Experimental. - **Typed newtypes** — `SessionId` and `RequestId` are `#[serde(transparent)]` newtypes around `String`, so the type system distinguishes a session identifier from an arbitrary `String` at compile time. Node/Python/Go diff --git a/rust/src/types.rs b/rust/src/types.rs index 8b9b5960a..2b4df1e52 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -830,7 +830,7 @@ impl CloudSessionOptions { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ExtensionInfo { - /// Extension namespace/source, e.g. `"github-app"`. + /// Extension namespace/source, e.g. `"example-host"`. pub source: String, /// Stable provider name within the source namespace. pub name: String, @@ -1369,6 +1369,33 @@ pub struct SessionConfig { /// `session.options.update` after create/resume. Defaults to `false` in /// [`crate::ClientMode::Empty`] when unset. pub manage_schedule_enabled: Option, + /// Capabilities to opt this session into via `enabledCapabilities` on + /// the `session.create` wire call. The runtime starts from a + /// `SDK_CAPABILITIES` baseline; this list extends it. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. On older + /// runtimes the field is silently ignored. + pub enabled_capabilities: Vec, + /// Capabilities to opt this session out of via `disabledCapabilities` + /// on the `session.create` wire call. Disable wins over enable on + /// overlap. See [`SessionCapability`]. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. + pub disabled_capabilities: Vec, } impl std::fmt::Debug for SessionConfig { @@ -1476,6 +1503,8 @@ impl std::fmt::Debug for SessionConfig { "system_message_transform", &self.system_message_transform.as_ref().map(|_| ""), ) + .field("enabled_capabilities", &self.enabled_capabilities) + .field("disabled_capabilities", &self.disabled_capabilities) .finish() } } @@ -1550,11 +1579,11 @@ impl Default for SessionConfig { custom_agents_local_only: None, coauthor_enabled: None, manage_schedule_enabled: None, + enabled_capabilities: Vec::new(), + disabled_capabilities: Vec::new(), } } } - -/// Runtime-only bundle drained out of a [`SessionConfig`] or /// [`ResumeSessionConfig`] by [`SessionConfig::into_wire`] / /// [`ResumeSessionConfig::into_wire`]. Holds the trait-object handlers, /// session-fs provider, and slash commands so the wire payload struct @@ -1574,6 +1603,27 @@ pub(crate) struct SessionConfigRuntime { pub commands: Option>, } +fn capability_list_to_wire( + field_name: &str, + capabilities: Vec, +) -> Result>, crate::Error> { + if capabilities.is_empty() { + return Ok(None); + } + + if capabilities + .iter() + .any(|capability| matches!(capability, SessionCapability::Unknown)) + { + return Err(crate::Error::with_message( + crate::ErrorKind::InvalidConfig, + format!("{field_name} cannot include SessionCapability::Unknown"), + )); + } + + Ok(Some(capabilities)) +} + impl SessionConfig { /// Consume this config to produce the [`SessionCreateWire`] payload /// for `session.create` and a [`SessionConfigRuntime`] bundle holding @@ -1623,6 +1673,10 @@ impl SessionConfig { let wire_canvases = self.canvases.clone(); let canvas_handler = self.canvas_handler.clone(); + let enabled_capabilities = + capability_list_to_wire("enabled_capabilities", self.enabled_capabilities)?; + let disabled_capabilities = + capability_list_to_wire("disabled_capabilities", self.disabled_capabilities)?; let wire = crate::wire::SessionCreateWire { session_id, model: self.model, @@ -1679,6 +1733,8 @@ impl SessionConfig { cloud: self.cloud, include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, commands: wire_commands, + enabled_capabilities, + disabled_capabilities, }; let runtime = SessionConfigRuntime { @@ -1889,6 +1945,87 @@ impl SessionConfig { self } + /// Opt this session into a capability via `enabledCapabilities` on + /// `session.create`. Appends to [`Self::enabled_capabilities`], + /// preserving insertion order. + /// + /// See [`SessionCapability`] for the available capability names. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. + /// + /// # Example + /// + /// ```rust,ignore + /// use github_copilot_sdk::{SessionCapability, SessionConfig}; + /// let config = SessionConfig::default() + /// .with_enable_capability(SessionCapability::Memory); + /// ``` + pub fn with_enable_capability(mut self, capability: SessionCapability) -> Self { + self.enabled_capabilities.push(capability); + self + } + + /// Opt this session out of a capability via `disabledCapabilities` on + /// `session.create`. Disable wins over enable on overlap. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. + pub fn with_disable_capability(mut self, capability: SessionCapability) -> Self { + self.disabled_capabilities.push(capability); + self + } + + /// Append the given capabilities to [`Self::enabled_capabilities`]. + /// Insertion order is preserved. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. + pub fn with_enabled_capabilities(mut self, capabilities: I) -> Self + where + I: IntoIterator, + { + self.enabled_capabilities.extend(capabilities); + self + } + + /// Append the given capabilities to [`Self::disabled_capabilities`]. + /// Insertion order is preserved. Disable wins over enable on overlap. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. + pub fn with_disabled_capabilities(mut self, capabilities: I) -> Self + where + I: IntoIterator, + { + self.disabled_capabilities.extend(capabilities); + self + } + /// Set stable extension identity metadata for this connection. pub fn with_extension_info(mut self, extension_info: ExtensionInfo) -> Self { self.extension_info = Some(extension_info); @@ -2346,6 +2483,30 @@ pub struct ResumeSessionConfig { pub coauthor_enabled: Option, /// See [`SessionConfig::manage_schedule_enabled`]. pub manage_schedule_enabled: Option, + /// Capabilities to opt this session into via `enabledCapabilities` on + /// the `session.resume` wire call. See [`SessionConfig::enabled_capabilities`]. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. + pub enabled_capabilities: Vec, + /// Capabilities to opt this session out of via `disabledCapabilities` on + /// the `session.resume` wire call. Disable wins over enable on overlap. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. + pub disabled_capabilities: Vec, } impl std::fmt::Debug for ResumeSessionConfig { @@ -2454,6 +2615,8 @@ impl std::fmt::Debug for ResumeSessionConfig { ) .field("suppress_resume_event", &self.suppress_resume_event) .field("continue_pending_work", &self.continue_pending_work) + .field("enabled_capabilities", &self.enabled_capabilities) + .field("disabled_capabilities", &self.disabled_capabilities) .finish() } } @@ -2502,6 +2665,11 @@ impl ResumeSessionConfig { let wire_canvases = self.canvases.clone(); let canvas_handler = self.canvas_handler.clone(); + let enabled_capabilities = + capability_list_to_wire("enabled_capabilities", self.enabled_capabilities)?; + let disabled_capabilities = + capability_list_to_wire("disabled_capabilities", self.disabled_capabilities)?; + let wire = crate::wire::SessionResumeWire { session_id: self.session_id, client_name: self.client_name, @@ -2559,6 +2727,8 @@ impl ResumeSessionConfig { commands: wire_commands, suppress_resume_event: self.suppress_resume_event, continue_pending_work: self.continue_pending_work, + enabled_capabilities, + disabled_capabilities, }; let runtime = SessionConfigRuntime { @@ -2648,6 +2818,8 @@ impl ResumeSessionConfig { custom_agents_local_only: None, coauthor_enabled: None, manage_schedule_enabled: None, + enabled_capabilities: Vec::new(), + disabled_capabilities: Vec::new(), } } @@ -2824,6 +2996,74 @@ impl ResumeSessionConfig { self } + /// Opt this resumed session into a capability via `enabledCapabilities` + /// on `session.resume`. See [`SessionConfig::with_enable_capability`]. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. + pub fn with_enable_capability(mut self, capability: SessionCapability) -> Self { + self.enabled_capabilities.push(capability); + self + } + + /// Opt this resumed session out of a capability. Disable wins on overlap. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. + pub fn with_disable_capability(mut self, capability: SessionCapability) -> Self { + self.disabled_capabilities.push(capability); + self + } + + /// Append the given capabilities to [`Self::enabled_capabilities`]. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. + pub fn with_enabled_capabilities(mut self, capabilities: I) -> Self + where + I: IntoIterator, + { + self.enabled_capabilities.extend(capabilities); + self + } + + /// Append the given capabilities to [`Self::disabled_capabilities`]. + /// Disable wins over enable on overlap. + /// + ///
+ /// + /// **Experimental.** This is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// + ///
+ /// + /// Requires runtime support for per-session capability controls. + pub fn with_disabled_capabilities(mut self, capabilities: I) -> Self + where + I: IntoIterator, + { + self.disabled_capabilities.extend(capabilities); + self + } + /// Set stable extension identity metadata for this connection on resume. pub fn with_extension_info(mut self, extension_info: ExtensionInfo) -> Self { self.extension_info = Some(extension_info); @@ -4173,6 +4413,7 @@ pub use crate::generated::api_types::{ Model, ModelBilling, ModelCapabilities, ModelCapabilitiesLimits, ModelCapabilitiesLimitsVision, ModelCapabilitiesSupports, ModelList, ModelPolicy, PermissionDecision, PermissionDecisionApproveOnce, PermissionDecisionReject, PermissionDecisionUserNotAvailable, + SessionCapability, }; /// Permission categories the CLI may request approval for. @@ -4634,7 +4875,7 @@ mod tests { .with_github_token("ghp_test") .with_enable_session_telemetry(false) .with_include_sub_agent_streaming_events(false) - .with_extension_info(ExtensionInfo::new("github-app", "counter")); + .with_extension_info(ExtensionInfo::new("example-host", "counter")); assert_eq!(cfg.session_id.as_ref().map(|s| s.as_str()), Some("sess-1")); assert_eq!(cfg.model.as_deref(), Some("claude-sonnet-4")); @@ -4672,7 +4913,7 @@ mod tests { assert_eq!(cfg.include_sub_agent_streaming_events, Some(false)); assert_eq!( cfg.extension_info, - Some(ExtensionInfo::new("github-app", "counter")) + Some(ExtensionInfo::new("example-host", "counter")) ); } @@ -4702,7 +4943,7 @@ mod tests { .with_include_sub_agent_streaming_events(true) .with_suppress_resume_event(true) .with_continue_pending_work(true) - .with_extension_info(ExtensionInfo::new("github-app", "counter")); + .with_extension_info(ExtensionInfo::new("example-host", "counter")); assert_eq!(cfg.session_id.as_str(), "sess-2"); assert_eq!(cfg.client_name.as_deref(), Some("test-app")); @@ -4740,7 +4981,7 @@ mod tests { assert_eq!(cfg.continue_pending_work, Some(true)); assert_eq!( cfg.extension_info, - Some(ExtensionInfo::new("github-app", "counter")) + Some(ExtensionInfo::new("example-host", "counter")) ); } @@ -5338,3 +5579,102 @@ mod permission_builder_tests { )); } } + +#[cfg(test)] +mod capability_tests { + use super::*; + use crate::SessionCapability; + + fn create_session_wire(config: SessionConfig) -> crate::wire::SessionCreateWire { + let (wire, _) = config + .into_wire(Some(SessionId::new("test-session"))) + .unwrap(); + wire + } + + fn resume_session_wire(config: ResumeSessionConfig) -> crate::wire::SessionResumeWire { + let (wire, _) = config.into_wire().unwrap(); + wire + } + + #[test] + fn session_config_empty_capabilities_omitted_from_wire() { + let wire = create_session_wire(SessionConfig::default()); + assert!(wire.enabled_capabilities.is_none()); + assert!(wire.disabled_capabilities.is_none()); + } + + #[test] + fn session_config_enabled_capabilities_serialized_on_wire() { + let config = SessionConfig::default() + .with_enable_capability(SessionCapability::Memory) + .with_enable_capability(SessionCapability::PlanMode) + .with_enable_capability(SessionCapability::CanvasRenderer); + let wire = create_session_wire(config); + let value = serde_json::to_value(&wire).unwrap(); + assert_eq!( + value["enabledCapabilities"], + serde_json::json!(["memory", "plan-mode", "canvas-renderer"]) + ); + assert!(wire.disabled_capabilities.is_none()); + } + + #[test] + fn session_config_disabled_capabilities_serialized_on_wire() { + let config = SessionConfig::default().with_disable_capability(SessionCapability::PlanMode); + let wire = create_session_wire(config); + assert!(wire.enabled_capabilities.is_none()); + let value = serde_json::to_value(&wire).unwrap(); + assert_eq!( + value["disabledCapabilities"], + serde_json::json!(["plan-mode"]) + ); + } + + #[test] + fn session_config_with_enabled_capabilities_appends() { + let config = SessionConfig::default() + .with_enable_capability(SessionCapability::Memory) + .with_enabled_capabilities([SessionCapability::PlanMode]); + let wire = create_session_wire(config); + assert_eq!( + wire.enabled_capabilities.as_ref().unwrap(), + &[SessionCapability::Memory, SessionCapability::PlanMode] + ); + } + + #[test] + fn session_config_unknown_capability_is_invalid() { + let config = SessionConfig::default().with_enable_capability(SessionCapability::Unknown); + let err = match config.into_wire(Some(SessionId::new("test-session"))) { + Ok(_) => panic!("expected Unknown to be rejected"), + Err(err) => err, + }; + assert_eq!(err.kind(), &crate::ErrorKind::InvalidConfig); + assert!( + err.to_string() + .contains("enabled_capabilities cannot include SessionCapability::Unknown") + ); + } + + #[test] + fn resume_session_config_empty_capabilities_omitted_from_wire() { + let wire = resume_session_wire(ResumeSessionConfig::new("sid".into())); + assert!(wire.enabled_capabilities.is_none()); + assert!(wire.disabled_capabilities.is_none()); + } + + #[test] + fn resume_session_config_capabilities_serialized_on_wire() { + let config = ResumeSessionConfig::new("sid".into()) + .with_enable_capability(SessionCapability::Memory) + .with_disable_capability(SessionCapability::PlanMode); + let wire = resume_session_wire(config); + let value = serde_json::to_value(&wire).unwrap(); + assert_eq!(value["enabledCapabilities"], serde_json::json!(["memory"])); + assert_eq!( + value["disabledCapabilities"], + serde_json::json!(["plan-mode"]) + ); + } +} diff --git a/rust/src/wire.rs b/rust/src/wire.rs index de40720b2..4a3f60b26 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -20,7 +20,7 @@ use serde::Serialize; use crate::canvas::CanvasDeclaration; use crate::generated::api_types::{ - ModelCapabilitiesOverride, OpenCanvasInstance, RemoteSessionMode, + ModelCapabilitiesOverride, OpenCanvasInstance, RemoteSessionMode, SessionCapability, }; use crate::generated::session_events::ReasoningSummary; use crate::types::{ @@ -147,6 +147,16 @@ pub(crate) struct SessionCreateWire { pub include_sub_agent_streaming_events: Option, #[serde(skip_serializing_if = "Option::is_none")] pub commands: Option>, + /// Capabilities to opt this session into. Forwarded as + /// `enabledCapabilities` on the `session.create` wire call. + /// Requires runtime support for per-session capability controls. + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled_capabilities: Option>, + /// Capabilities to opt this session out of. Disable wins on overlap. + /// Forwarded as `disabledCapabilities` on the `session.create` wire call. + /// Requires runtime support for per-session capability controls. + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_capabilities: Option>, } /// The exact JSON shape sent on the `session.resume` JSON-RPC request. @@ -257,4 +267,14 @@ pub(crate) struct SessionResumeWire { pub suppress_resume_event: Option, #[serde(skip_serializing_if = "Option::is_none")] pub continue_pending_work: Option, + /// Capabilities to opt this session into. Forwarded as + /// `enabledCapabilities` on the `session.resume` wire call. + /// Requires runtime support for per-session capability controls. + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled_capabilities: Option>, + /// Capabilities to opt this session out of. Disable wins on overlap. + /// Forwarded as `disabledCapabilities` on the `session.resume` wire call. + /// Requires runtime support for per-session capability controls. + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_capabilities: Option>, }