diff --git a/rust/README.md b/rust/README.md index 215448e05..0a8534232 100644 --- a/rust/README.md +++ b/rust/README.md @@ -80,6 +80,105 @@ 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, disable, or declare provider support for 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 three 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) +- `capabilityProviders` -- capabilities this client actively *provides* (e.g. renders a canvas, drives elicitation) + +This approach works for every transport -- including `Transport::External` (Desktop app / shared CLI server) -- because it does not rely on CLI spawn arguments. + +> **Runtime dependency.** Per-session capability controls require +> [github/copilot-agent-runtime#8918](https://github.com/github/copilot-agent-runtime/pull/8918) +> or later. On older runtimes the fields are silently ignored. +> Pairs with [github/agents#981](https://github.com/github/agents/issues/981) +> (Desktop app missing memory capability). + +Use `SessionConfig::with_enable_capability` / `with_disable_capability` / `with_capability_provider` (and their plural counterparts): + +```rust,ignore +use github_copilot_sdk::{SessionCapability, SessionConfig}; + +let session = client.create_session( + SessionConfig::default() + .with_enable_capability(SessionCapability::Memory) + // Declare that this client provides the canvas renderer. + .with_capability_provider(SessionCapability::CanvasRenderer), + "What is 2 + 2?".into(), +).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) + .with_capability_provider(SessionCapability::Elicitation), + None, +).await?; +``` + +**Variants:** + +| Variant | Wire name | Description | +| -------------------- | ----------------------- | ----------------------------------------------------- | +| `TuiHints` | `tui-hints` | TUI keyboard shortcuts | +| `PlanMode` | `plan-mode` | `[[PLAN]]` handling and plan-mode instructions | +| `Memory` | `memory` | `store_memory` tool and `` system-prompt section | +| `CliDocumentation` | `cli-documentation` | `fetch_copilot_cli_documentation` tool and `` section | +| `AskUser` | `ask-user` | `ask_user` tool for interactive clarification | +| `InteractiveMode` | `interactive-mode` | Interactive-CLI identity (vs headless) | +| `SystemNotifications`| `system-notifications` | Automatic batched system notifications to the agent | +| `Elicitation` | `elicitation` | Elicitation prompts (confirm / select / input) | +| `McpApps` | `mcp-apps` | MCP-Apps `ui://` resource passthrough (SEP-1865) | +| `CanvasRenderer` | `canvas-renderer` | Host-rendered extension canvases | +| `Other(String)` | *(verbatim)* | Forward-compat escape hatch for unknown future 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. + +**Capability providers vs enable/disable.** `enabledCapabilities` and +`disabledCapabilities` control which server-side capabilities are active. +`capabilityProviders` is different: it tells the runtime that *this client* +provides a capability end-to-end (for example, renders a canvas, drives the +elicitation flow). The runtime uses the provider list to negotiate the +capability handshake with the model. + +> **Note:** `requestCanvasRenderer` and `requestElicitation` are legacy field +> aliases for provider registration and remain supported for backward +> compatibility. The `capabilityProviders` field is the forward-looking API. + +**Forward compatibility.** The enum is `#[non_exhaustive]` and carries an +`Other(String)` variant so callers on older SDK builds can opt into +capabilities that the runtime adds ahead of a new SDK release, without any +recompile-blocking enum-variant additions: + +```rust,ignore +use github_copilot_sdk::{SessionCapability, SessionConfig}; + +// Opt into a capability the SDK doesn't know about yet. +let config = SessionConfig::default() + .with_enable_capability(SessionCapability::Other("future-cap".to_string())); +``` + +`&str` and `String` implement `Into`, so you can also pass +string literals directly to the builders: + +```rust,ignore +use github_copilot_sdk::SessionConfig; + +let config = SessionConfig::default() + .with_enable_capability("memory") // &str coerces to SessionCapability + .with_disable_capability("plan-mode"); +``` + ### 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()`. @@ -716,6 +815,13 @@ 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, `#[non_exhaustive]` enum for per-session + capability opt-in / opt-out / provider declaration, with an `Other(String)` escape + hatch for forward compatibility. Sent via `enabledCapabilities` / + `disabledCapabilities` / `capabilityProviders` on the `session.create` and + `session.resume` wire calls -- works for all transports including + `Transport::External`. See [Session capabilities](#session-capabilities) above. + Node/Python/Go/.NET accept stringly-typed flags. - **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/lib.rs b/rust/src/lib.rs index 4528b5deb..53b492296 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -352,6 +352,129 @@ impl OtelExporterType { } } +/// A named session capability sent in the `session.create` and +/// `session.resume` wire payloads. +/// +/// Capabilities gate optional CLI features (extra tools, system-prompt +/// sections, host-rendered surfaces). The runtime starts from a +/// hard-coded `SDK_CAPABILITIES` set; use +/// [`SessionConfig::with_enable_capability`] / +/// [`SessionConfig::with_disable_capability`] (and their plural +/// counterparts) to opt individual sessions in or out. +/// +/// > **Not** the same as [`SessionCapabilities`] — that struct is the +/// > *runtime-negotiated* capability descriptor reported by the CLI on +/// > `session.create`. [`SessionCapability`] is the *opt-in / opt-out +/// > toggle name* sent with each `session.create` / `session.resume`. +/// +/// The runtime's overlap semantics are **disable-wins**: if a capability +/// appears in both the enabled and disabled lists, the disable wins. +/// The SDK preserves the order callers add capabilities in so the +/// resulting wire payload is deterministic. +/// +/// The enum is `#[non_exhaustive]` and carries an [`Other`](Self::Other) +/// variant so forward-compat capabilities the runtime grows ahead of an +/// SDK release can still be opted into without waiting for a new +/// enum variant. +/// +/// Requires github/copilot-agent-runtime#8918 or later. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum SessionCapability { + /// TUI-only prompt hints (keyboard shortcuts). + TuiHints, + /// `[[PLAN]]` handling and plan-mode instructions. + PlanMode, + /// `store_memory` tool and the `` system-prompt section. + Memory, + /// `fetch_copilot_cli_documentation` tool plus the + /// `` system-prompt section. + CliDocumentation, + /// `ask_user` tool for interactive clarification. + AskUser, + /// Interactive-CLI identity (vs non-interactive / headless). + InteractiveMode, + /// Automatic system notifications to the agent (batched, hidden + /// from the user timeline). + SystemNotifications, + /// Elicitation support (confirm / select / input prompts). + Elicitation, + /// MCP-Apps (SEP-1865) `ui://` resource passthrough. + McpApps, + /// Extension-provided canvases rendered by the host. + CanvasRenderer, + /// A capability name the SDK doesn't have a typed variant for yet. + /// + /// Pass any kebab-case capability string here to forward it + /// verbatim to the runtime. + Other(String), +} + +impl SessionCapability { + /// The kebab-case wire string sent in `enabledCapabilities` / + /// `disabledCapabilities` on `session.create` and `session.resume`. + pub fn as_str(&self) -> &str { + match self { + Self::TuiHints => "tui-hints", + Self::PlanMode => "plan-mode", + Self::Memory => "memory", + Self::CliDocumentation => "cli-documentation", + Self::AskUser => "ask-user", + Self::InteractiveMode => "interactive-mode", + Self::SystemNotifications => "system-notifications", + Self::Elicitation => "elicitation", + Self::McpApps => "mcp-apps", + Self::CanvasRenderer => "canvas-renderer", + Self::Other(name) => name.as_str(), + } + } +} + +impl std::fmt::Display for SessionCapability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for SessionCapability { + type Err = std::convert::Infallible; + + /// Parse a kebab-case capability name. Unknown names round-trip + /// through [`SessionCapability::Other`] so old SDK builds stay + /// useful against CLIs that add new capabilities. Always returns + /// `Ok` — the error type is [`Infallible`](std::convert::Infallible). + fn from_str(s: &str) -> std::result::Result { + Ok(match s { + "tui-hints" => Self::TuiHints, + "plan-mode" => Self::PlanMode, + "memory" => Self::Memory, + "cli-documentation" => Self::CliDocumentation, + "ask-user" => Self::AskUser, + "interactive-mode" => Self::InteractiveMode, + "system-notifications" => Self::SystemNotifications, + "elicitation" => Self::Elicitation, + "mcp-apps" => Self::McpApps, + "canvas-renderer" => Self::CanvasRenderer, + other => Self::Other(other.to_owned()), + }) + } +} + +impl From<&str> for SessionCapability { + fn from(s: &str) -> Self { + // FromStr::from_str is Infallible — unwrap is safe. + s.parse() + .expect("SessionCapability::from_str is Infallible") + } +} + +impl From for SessionCapability { + fn from(s: String) -> Self { + s.as_str().into() + } +} + /// OpenTelemetry configuration forwarded to the spawned GitHub Copilot CLI /// process. /// @@ -2329,6 +2452,47 @@ mod tests { assert_eq!(Client::remote_args(&opts), vec!["--remote".to_string()]); } + #[test] + fn session_capability_round_trips_via_str() { + for cap in [ + SessionCapability::TuiHints, + SessionCapability::PlanMode, + SessionCapability::Memory, + SessionCapability::CliDocumentation, + SessionCapability::AskUser, + SessionCapability::InteractiveMode, + SessionCapability::SystemNotifications, + SessionCapability::Elicitation, + SessionCapability::McpApps, + SessionCapability::CanvasRenderer, + ] { + let s = cap.to_string(); + let parsed: SessionCapability = s.parse().unwrap(); + assert_eq!(parsed, cap, "round-trip failed for {s}"); + } + } + + #[test] + fn session_capability_from_str_falls_back_to_other_for_unknown_names() { + let parsed: SessionCapability = "brand-new-cap".parse().unwrap(); + assert_eq!( + parsed, + SessionCapability::Other("brand-new-cap".to_string()) + ); + assert_eq!(parsed.as_str(), "brand-new-cap"); + } + + #[test] + fn session_capability_into_from_str_and_string() { + let from_str: SessionCapability = "memory".into(); + let from_string: SessionCapability = "memory".to_string().into(); + assert_eq!(from_str, SessionCapability::Memory); + assert_eq!(from_string, SessionCapability::Memory); + // Unknown names go to Other + let other: SessionCapability = "future-cap".into(); + assert_eq!(other, SessionCapability::Other("future-cap".to_string())); + } + #[test] fn log_level_args_omitted_when_unset() { let opts = ClientOptions::default(); diff --git a/rust/src/types.rs b/rust/src/types.rs index 97d726994..bbea710d6 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -12,6 +12,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::SessionCapability; use crate::canvas::{CanvasDeclaration, CanvasHandler}; use crate::generated::api_types::OpenCanvasInstance; use crate::handler::{ @@ -1250,6 +1251,32 @@ 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. + /// + /// Requires github/copilot-agent-runtime#8918 or later. 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`]. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub disabled_capabilities: Vec, + /// Capabilities this session actively provides, forwarded as + /// `capabilityProviders` on the `session.create` wire call. + /// + /// Use this to declare that your client handles a capability end-to-end + /// (for example, renders a canvas, drives elicitation). The runtime uses + /// the list to negotiate capability handshake with the model. + /// + /// Note: `requestCanvasRenderer` and `requestElicitation` are legacy + /// aliases for provider registration and remain supported for + /// compatibility. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub capability_providers: Vec, } impl std::fmt::Debug for SessionConfig { @@ -1330,6 +1357,9 @@ 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) + .field("capability_providers", &self.capability_providers) .finish() } } @@ -1389,11 +1419,12 @@ impl Default for SessionConfig { custom_agents_local_only: None, coauthor_enabled: None, manage_schedule_enabled: None, + enabled_capabilities: Vec::new(), + disabled_capabilities: Vec::new(), + capability_providers: 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 @@ -1462,6 +1493,36 @@ impl SessionConfig { let wire_canvases = self.canvases.clone(); let canvas_handler = self.canvas_handler.clone(); + let enabled_capabilities = if self.enabled_capabilities.is_empty() { + None + } else { + Some( + self.enabled_capabilities + .iter() + .map(|c| c.to_string()) + .collect(), + ) + }; + let disabled_capabilities = if self.disabled_capabilities.is_empty() { + None + } else { + Some( + self.disabled_capabilities + .iter() + .map(|c| c.to_string()) + .collect(), + ) + }; + let capability_providers = if self.capability_providers.is_empty() { + None + } else { + Some( + self.capability_providers + .iter() + .map(|c| c.to_string()) + .collect(), + ) + }; let wire = crate::wire::SessionCreateWire { session_id, model: self.model, @@ -1503,6 +1564,9 @@ impl SessionConfig { cloud: self.cloud, include_sub_agent_streaming_events: self.include_sub_agent_streaming_events, commands: wire_commands, + enabled_capabilities, + disabled_capabilities, + capability_providers, }; let runtime = SessionConfigRuntime { @@ -1693,6 +1757,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 disable-wins overlap rules + /// and the [`SessionCapability::Other`] forward-compat escape hatch. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + /// + /// # 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: impl Into) -> Self { + self.enabled_capabilities.push(capability.into()); + self + } + + /// Opt this session out of a capability via `disabledCapabilities` on + /// `session.create`. Disable wins over enable on overlap. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub fn with_disable_capability(mut self, capability: impl Into) -> Self { + self.disabled_capabilities.push(capability.into()); + self + } + + /// Replace [`Self::enabled_capabilities`] with the given iterable. + /// Insertion order is preserved. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub fn with_enabled_capabilities(mut self, capabilities: I) -> Self + where + I: IntoIterator, + C: Into, + { + self.enabled_capabilities = capabilities.into_iter().map(Into::into).collect(); + self + } + + /// Replace [`Self::disabled_capabilities`] with the given iterable. + /// Insertion order is preserved. Disable wins over enable on overlap. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub fn with_disabled_capabilities(mut self, capabilities: I) -> Self + where + I: IntoIterator, + C: Into, + { + self.disabled_capabilities = capabilities.into_iter().map(Into::into).collect(); + self + } + + /// Append a single capability to [`Self::capability_providers`]. + /// + /// Use this to declare that your client provides a capability + /// (e.g. [`SessionCapability::CanvasRenderer`], + /// [`SessionCapability::Elicitation`]). + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub fn with_capability_provider(mut self, capability: impl Into) -> Self { + self.capability_providers.push(capability.into()); + self + } + + /// Replace [`Self::capability_providers`] with the given iterable. + /// Insertion order is preserved. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub fn with_capability_providers(mut self, capabilities: I) -> Self + where + I: IntoIterator, + C: Into, + { + self.capability_providers = capabilities.into_iter().map(Into::into).collect(); + 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); @@ -2018,6 +2163,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`]. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + 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. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub disabled_capabilities: Vec, + /// Capabilities this session actively provides, forwarded as + /// `capabilityProviders` on the `session.resume` wire call. + /// + /// Use this to declare that your client provides a capability + /// (e.g. [`SessionCapability::CanvasRenderer`], + /// [`SessionCapability::Elicitation`]). The runtime uses the list to + /// negotiate capability handshake with the model. + /// + /// Note: `requestCanvasRenderer` and `requestElicitation` are legacy + /// aliases for provider registration and remain supported for + /// compatibility. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub capability_providers: Vec, } impl std::fmt::Debug for ResumeSessionConfig { @@ -2099,6 +2268,9 @@ 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) + .field("capability_providers", &self.capability_providers) .finish() } } @@ -2147,6 +2319,37 @@ impl ResumeSessionConfig { let wire_canvases = self.canvases.clone(); let canvas_handler = self.canvas_handler.clone(); + let enabled_capabilities = if self.enabled_capabilities.is_empty() { + None + } else { + Some( + self.enabled_capabilities + .iter() + .map(|c| c.to_string()) + .collect(), + ) + }; + let disabled_capabilities = if self.disabled_capabilities.is_empty() { + None + } else { + Some( + self.disabled_capabilities + .iter() + .map(|c| c.to_string()) + .collect(), + ) + }; + let capability_providers = if self.capability_providers.is_empty() { + None + } else { + Some( + self.capability_providers + .iter() + .map(|c| c.to_string()) + .collect(), + ) + }; + let wire = crate::wire::SessionResumeWire { session_id: self.session_id, client_name: self.client_name, @@ -2189,6 +2392,9 @@ impl ResumeSessionConfig { commands: wire_commands, suppress_resume_event: self.suppress_resume_event, continue_pending_work: self.continue_pending_work, + enabled_capabilities, + disabled_capabilities, + capability_providers, }; let runtime = SessionConfigRuntime { @@ -2263,6 +2469,9 @@ impl ResumeSessionConfig { custom_agents_local_only: None, coauthor_enabled: None, manage_schedule_enabled: None, + enabled_capabilities: Vec::new(), + disabled_capabilities: Vec::new(), + capability_providers: Vec::new(), } } @@ -2418,6 +2627,73 @@ impl ResumeSessionConfig { self } + /// Opt this resumed session into a capability via `enabledCapabilities` + /// on `session.resume`. See [`SessionConfig::with_enable_capability`]. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub fn with_enable_capability(mut self, capability: impl Into) -> Self { + self.enabled_capabilities.push(capability.into()); + self + } + + /// Opt this resumed session out of a capability. Disable wins on overlap. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub fn with_disable_capability(mut self, capability: impl Into) -> Self { + self.disabled_capabilities.push(capability.into()); + self + } + + /// Replace [`Self::enabled_capabilities`] with the given iterable. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub fn with_enabled_capabilities(mut self, capabilities: I) -> Self + where + I: IntoIterator, + C: Into, + { + self.enabled_capabilities = capabilities.into_iter().map(Into::into).collect(); + self + } + + /// Replace [`Self::disabled_capabilities`] with the given iterable. + /// Disable wins over enable on overlap. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub fn with_disabled_capabilities(mut self, capabilities: I) -> Self + where + I: IntoIterator, + C: Into, + { + self.disabled_capabilities = capabilities.into_iter().map(Into::into).collect(); + self + } + + /// Append a single capability to [`Self::capability_providers`]. + /// + /// Use this to declare that your client provides a capability + /// (e.g. [`SessionCapability::CanvasRenderer`], + /// [`SessionCapability::Elicitation`]). + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub fn with_capability_provider(mut self, capability: impl Into) -> Self { + self.capability_providers.push(capability.into()); + self + } + + /// Replace [`Self::capability_providers`] with the given iterable. + /// Insertion order is preserved. + /// + /// Requires github/copilot-agent-runtime#8918 or later. + pub fn with_capability_providers(mut self, capabilities: I) -> Self + where + I: IntoIterator, + C: Into, + { + self.capability_providers = capabilities.into_iter().map(Into::into).collect(); + 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); @@ -4684,3 +4960,134 @@ 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); + let wire = create_session_wire(config); + let enabled = wire.enabled_capabilities.as_ref().unwrap(); + assert_eq!(enabled, &["memory".to_string(), "plan-mode".to_string()]); + 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 disabled = wire.disabled_capabilities.as_ref().unwrap(); + assert_eq!(disabled, &["plan-mode".to_string()]); + } + + #[test] + fn session_config_with_enabled_capabilities_replaces() { + let config = SessionConfig::default() + .with_enable_capability(SessionCapability::Memory) + .with_enabled_capabilities([SessionCapability::PlanMode]); + let wire = create_session_wire(config); + let enabled = wire.enabled_capabilities.as_ref().unwrap(); + assert_eq!(enabled, &["plan-mode".to_string()]); + } + + #[test] + fn session_config_other_capability_round_trips_through_wire() { + let config = SessionConfig::default() + .with_enable_capability(SessionCapability::Other("custom-cap".to_string())); + let wire = create_session_wire(config); + let enabled = wire.enabled_capabilities.as_ref().unwrap(); + assert_eq!(enabled, &["custom-cap".to_string()]); + } + + #[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 enabled = wire.enabled_capabilities.as_ref().unwrap(); + let disabled = wire.disabled_capabilities.as_ref().unwrap(); + assert_eq!(enabled, &["memory".to_string()]); + assert_eq!(disabled, &["plan-mode".to_string()]); + } + + #[test] + fn session_config_empty_providers_omitted_from_wire() { + let wire = create_session_wire(SessionConfig::default()); + assert!(wire.capability_providers.is_none()); + } + + #[test] + fn session_config_capability_providers_serialized_on_wire() { + let config = SessionConfig::default() + .with_capability_provider(SessionCapability::CanvasRenderer) + .with_capability_provider(SessionCapability::Elicitation); + let wire = create_session_wire(config); + let providers = wire.capability_providers.as_ref().unwrap(); + assert_eq!( + providers, + &["canvas-renderer".to_string(), "elicitation".to_string()] + ); + } + + #[test] + fn session_config_with_capability_providers_replaces() { + let config = SessionConfig::default() + .with_capability_provider(SessionCapability::CanvasRenderer) + .with_capability_providers([SessionCapability::Elicitation]); + let wire = create_session_wire(config); + let providers = wire.capability_providers.as_ref().unwrap(); + assert_eq!(providers, &["elicitation".to_string()]); + } + + #[test] + fn resume_session_config_empty_providers_omitted_from_wire() { + let wire = resume_session_wire(ResumeSessionConfig::new("sid".into())); + assert!(wire.capability_providers.is_none()); + } + + #[test] + fn resume_session_config_capability_providers_serialized_on_wire() { + let config = ResumeSessionConfig::new("sid".into()) + .with_capability_provider(SessionCapability::CanvasRenderer) + .with_capability_provider(SessionCapability::Elicitation); + let wire = resume_session_wire(config); + let providers = wire.capability_providers.as_ref().unwrap(); + assert_eq!( + providers, + &["canvas-renderer".to_string(), "elicitation".to_string()] + ); + } +} diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 15d137760..87b1e0d7c 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -116,6 +116,24 @@ 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 github/copilot-agent-runtime#8918. + #[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 github/copilot-agent-runtime#8918. + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_capabilities: Option>, + /// Capabilities this session actively provides (e.g. `CanvasRenderer`, + /// `Elicitation`). Forwarded as `capabilityProviders` on the + /// `session.create` wire call. Requires github/copilot-agent-runtime#8918. + /// + /// Note: `requestCanvasRenderer` and `requestElicitation` are legacy + /// aliases for provider registration and remain for compatibility. + #[serde(skip_serializing_if = "Option::is_none")] + pub capability_providers: Option>, } /// The exact JSON shape sent on the `session.resume` JSON-RPC request. @@ -197,4 +215,22 @@ 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 github/copilot-agent-runtime#8918. + #[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 github/copilot-agent-runtime#8918. + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_capabilities: Option>, + /// Capabilities this session actively provides. Forwarded as + /// `capabilityProviders` on the `session.resume` wire call. + /// Requires github/copilot-agent-runtime#8918. + /// + /// Note: `requestCanvasRenderer` and `requestElicitation` are legacy + /// aliases for provider registration and remain for compatibility. + #[serde(skip_serializing_if = "Option::is_none")] + pub capability_providers: Option>, }