From 0b225969902f720607f91dee50bb5ac9466ebb78 Mon Sep 17 00:00:00 2001 From: Andy Adams-Moran Date: Wed, 27 May 2026 15:46:25 +0100 Subject: [PATCH] rust: add typed SessionCapability enum and ClientOptions builders Adds a typed `SessionCapability` enum and matching `ClientOptions` fields plus builder methods to the Rust SDK, so callers can express "enable memory", "disable bash", etc. without stringly-typed flags. - `SessionCapability` is `#[non_exhaustive]`, kebab-case-serialized, and has an `Other(String)` escape hatch for forward compatibility with capabilities the runtime adds. - `ClientOptions` gains `enabled_capabilities` and `disabled_capabilities` vectors and four builders: `with_enable_capability`, `with_disable_capability`, `with_enabled_capabilities`, `with_disabled_capabilities`. - `Client::capability_args` emits the corresponding CLI flags (enables first, then disables, in insertion order), threaded into both `spawn_stdio` and `spawn_tcp` between `remote_args` and `extra_args`. Disable wins on conflict. Pairs with github/copilot-agent-runtime#8029 (CLI flags) and github/agents#981 (Desktop missing memory capability). 11 new unit tests cover the enum's `Display`, `FromStr`, `From<&str>`, `From`, serde round-trip, and the capability-args ordering / disable-wins semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/README.md | 73 ++++++++++ rust/src/lib.rs | 359 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 432 insertions(+) diff --git a/rust/README.md b/rust/README.md index 00e26dbaa..4ccf6d770 100644 --- a/rust/README.md +++ b/rust/README.md @@ -77,9 +77,77 @@ client.stop().await?; | `env_remove` | `Vec` | Environment variables to remove | | `extra_args` | `Vec` | Extra CLI flags | | `transport` | `Transport` | `Stdio` (default), `Tcp { port }`, or `External { host, port }` | +| `enabled_capabilities` | `Vec` | Capabilities to opt into at spawn time (e.g. `Memory`, `Elicitation`) | +| `disabled_capabilities` | `Vec` | Capabilities to opt out of at spawn time; disable wins on overlap | 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 + +`SessionCapability` is a typed enum of CLI feature toggles passed to the +spawned process via `--enable-capability` / `--disable-capability`. Use +`ClientOptions::with_enable_capability` or `with_disable_capability` (and their +plural counterparts) to build up the opt-in / opt-out lists: + +```rust,ignore +use github_copilot_sdk::{Client, ClientOptions, SessionCapability}; + +let client = Client::start( + ClientOptions::new() + .with_enable_capability(SessionCapability::Memory) + .with_enable_capability(SessionCapability::Elicitation), +).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`, the disable wins. The SDK +emits all enable flags first, then all disable flags, in insertion order, so +the resulting `argv` is deterministic. + +**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::{ClientOptions, SessionCapability}; + +// Opt into a capability the SDK doesn't know about yet. +let opts = ClientOptions::new() + .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::ClientOptions; + +let opts = ClientOptions::new() + .with_enable_capability("memory") // &str coerces to SessionCapability + .with_disable_capability("plan-mode"); +``` + +> Pairs with [github/copilot-agent-runtime#8029](https://github.com/github/copilot-agent-runtime/pull/8029) +> (CLI flag plumbing) and [github/agents#981](https://github.com/github/agents/issues/981) +> (Desktop app missing memory capability). + ### 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 +784,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, `#[non_exhaustive]` enum for capability + opt-in / opt-out toggles passed to the CLI at spawn time, with an + `Other(String)` escape hatch for forward compatibility. 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 cad6ee629..ca928106f 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -426,6 +426,22 @@ pub struct ClientOptions { /// Ignored when the SDK was built without a bundled CLI (i.e. with /// `default-features = false` to disable the `bundled-cli` feature). pub bundled_cli_extract_dir: Option, + /// Session capabilities to opt the spawned CLI into via repeated + /// `--enable-capability=` flags. Insertion order is + /// preserved. + /// + /// Capabilities default to a CLI-chosen `SDK_CAPABILITIES` set when + /// no flags are passed; this field lets a host opt into capabilities + /// the CLI excludes by default (e.g. [`SessionCapability::Memory`]). + /// See [`SessionCapability`] for the disable-wins overlap rules. + pub enabled_capabilities: Vec, + /// Session capabilities to opt the spawned CLI out of via repeated + /// `--disable-capability=` flags. Insertion order is + /// preserved. + /// + /// Disable wins over enable on overlap, both within this struct and + /// against the CLI's default set. See [`SessionCapability`]. + pub disabled_capabilities: Vec, } impl std::fmt::Debug for ClientOptions { @@ -461,6 +477,8 @@ impl std::fmt::Debug for ClientOptions { .field("base_directory", &self.base_directory) .field("enable_remote_sessions", &self.enable_remote_sessions) .field("bundled_cli_extract_dir", &self.bundled_cli_extract_dir) + .field("enabled_capabilities", &self.enabled_capabilities) + .field("disabled_capabilities", &self.disabled_capabilities) .finish() } } @@ -543,6 +561,131 @@ impl OtelExporterType { } } +/// A named session capability that the CLI's `--server` mode can opt +/// into or out of at startup. +/// +/// Capabilities gate optional CLI features (extra tools, system-prompt +/// sections, host-rendered surfaces). The CLI ships a hard-coded +/// `SDK_CAPABILITIES` set that is intentionally a strict subset of +/// every available capability — opting into things like +/// [`SessionCapability::Memory`] or [`SessionCapability::Elicitation`] +/// requires passing them through to the spawned process. Use +/// [`ClientOptions::with_enable_capability`] / +/// [`ClientOptions::with_disable_capability`] (and their plural +/// counterparts) for that. +/// +/// > **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 at spawn time. +/// +/// The CLI'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 argv is deterministic. +/// +/// The enum is `#[non_exhaustive]` and carries an [`Other`](Self::Other) +/// variant so forward-compat capabilities the CLI grows ahead of an +/// SDK release can still be opted into without waiting for a new +/// enum variant. +#[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. The CLI will reject unknown names at startup if it + /// has a closed enum of capabilities (today it doesn't). + Other(String), +} + +impl SessionCapability { + /// The kebab-case wire name passed to `--enable-capability` / + /// `--disable-capability`. + 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) -> 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. /// @@ -668,6 +811,8 @@ impl Default for ClientOptions { base_directory: None, enable_remote_sessions: false, bundled_cli_extract_dir: None, + enabled_capabilities: Vec::new(), + disabled_capabilities: Vec::new(), } } } @@ -831,6 +976,65 @@ impl ClientOptions { self.bundled_cli_extract_dir = Some(dir.into()); self } + + /// Opt the spawned CLI into a session capability via + /// `--enable-capability=`. Appends to + /// [`Self::enabled_capabilities`] preserving insertion order. + /// + /// Calling this multiple times with the same capability is + /// idempotent on the wire (the CLI deduplicates), but the SDK + /// preserves the call order in [`Self::enabled_capabilities`]. + /// + /// See [`SessionCapability`] for the disable-wins overlap rules + /// and the [`SessionCapability::Other`] forward-compat escape hatch. + /// + /// # Example + /// + /// ``` + /// # use github_copilot_sdk::{ClientOptions, SessionCapability}; + /// let opts = ClientOptions::new() + /// .with_enable_capability(SessionCapability::Memory) + /// .with_enable_capability(SessionCapability::Elicitation); + /// ``` + pub fn with_enable_capability(mut self, capability: impl Into) -> Self { + self.enabled_capabilities.push(capability.into()); + self + } + + /// Opt the spawned CLI out of a session capability via + /// `--disable-capability=`. Appends to + /// [`Self::disabled_capabilities`] preserving insertion order. + /// + /// Disables win over enables on overlap. See [`SessionCapability`]. + 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. + /// Useful when configuring from a deployment manifest. Insertion + /// order is preserved. + 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. + /// Useful when configuring from a deployment manifest. Insertion + /// order is preserved. See [`SessionCapability`] for the + /// disable-wins overlap rules. + 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 + } } /// Validate a [`SessionFsConfig`] before sending `sessionFs.setProvider`. @@ -1378,6 +1582,27 @@ impl Client { } } + /// Returns repeated `--enable-capability=` / + /// `--disable-capability=` flags for each capability in + /// [`ClientOptions::enabled_capabilities`] and + /// [`ClientOptions::disabled_capabilities`]. Order: enables in + /// insertion order, then disables in insertion order. The CLI's + /// disable-wins-on-overlap semantics are unaffected by SDK-side + /// ordering — both flags are independent — so a stable order is + /// purely for argv determinism. + fn capability_args(options: &ClientOptions) -> Vec { + let mut args = Vec::with_capacity( + options.enabled_capabilities.len() + options.disabled_capabilities.len(), + ); + for cap in &options.enabled_capabilities { + args.push(format!("--enable-capability={cap}")); + } + for cap in &options.disabled_capabilities { + args.push(format!("--disable-capability={cap}")); + } + args + } + fn log_level_args(options: &ClientOptions) -> Vec<&'static str> { match options.log_level { Some(level) => vec!["--log-level", level.as_str()], @@ -1394,6 +1619,7 @@ impl Client { .args(Self::auth_args(options)) .args(Self::session_idle_timeout_args(options)) .args(Self::remote_args(options)) + .args(Self::capability_args(options)) .args(&options.extra_args) .stdin(Stdio::piped()); let spawn_start = Instant::now(); @@ -1418,6 +1644,7 @@ impl Client { .args(Self::auth_args(options)) .args(Self::session_idle_timeout_args(options)) .args(Self::remote_args(options)) + .args(Self::capability_args(options)) .args(&options.extra_args) .stdin(Stdio::null()); let spawn_start = Instant::now(); @@ -2418,6 +2645,138 @@ mod tests { assert_eq!(Client::remote_args(&opts), vec!["--remote".to_string()]); } + #[test] + fn capability_args_omitted_by_default() { + let opts = ClientOptions::default(); + assert!(Client::capability_args(&opts).is_empty()); + } + + #[test] + fn capability_args_emit_typed_enable_flags_in_insertion_order() { + let opts = ClientOptions::new() + .with_enable_capability(SessionCapability::Memory) + .with_enable_capability(SessionCapability::Elicitation); + assert_eq!( + Client::capability_args(&opts), + vec![ + "--enable-capability=memory".to_string(), + "--enable-capability=elicitation".to_string(), + ], + ); + } + + #[test] + fn capability_args_emit_typed_disable_flags_in_insertion_order() { + let opts = ClientOptions::new() + .with_disable_capability(SessionCapability::PlanMode) + .with_disable_capability(SessionCapability::Memory); + assert_eq!( + Client::capability_args(&opts), + vec![ + "--disable-capability=plan-mode".to_string(), + "--disable-capability=memory".to_string(), + ], + ); + } + + #[test] + fn capability_args_emit_enables_before_disables() { + let opts = ClientOptions::new() + .with_enable_capability(SessionCapability::Memory) + .with_disable_capability(SessionCapability::PlanMode); + assert_eq!( + Client::capability_args(&opts), + vec![ + "--enable-capability=memory".to_string(), + "--disable-capability=plan-mode".to_string(), + ], + "enables should be emitted before disables for argv determinism", + ); + } + + #[test] + fn capability_args_forwards_other_variant_verbatim() { + let opts = ClientOptions::new() + .with_enable_capability(SessionCapability::Other("future-cap".to_string())); + assert_eq!( + Client::capability_args(&opts), + vec!["--enable-capability=future-cap".to_string()], + ); + } + + #[test] + fn with_enabled_capabilities_replaces_existing_list() { + let opts = ClientOptions::new() + .with_enable_capability(SessionCapability::Memory) + .with_enabled_capabilities([ + SessionCapability::Elicitation, + SessionCapability::McpApps, + ]); + assert_eq!( + opts.enabled_capabilities, + vec![SessionCapability::Elicitation, SessionCapability::McpApps], + "with_enabled_capabilities should replace, not append", + ); + } + + #[test] + fn with_disabled_capabilities_replaces_existing_list() { + let opts = ClientOptions::new() + .with_disable_capability(SessionCapability::Memory) + .with_disabled_capabilities([SessionCapability::PlanMode]); + assert_eq!( + opts.disabled_capabilities, + vec![SessionCapability::PlanMode] + ); + } + + #[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 capability_args_strings_accepted_via_into() { + let opts = ClientOptions::new() + .with_enable_capability("memory") + .with_disable_capability("plan-mode".to_string()); + assert_eq!( + opts.enabled_capabilities, + vec![SessionCapability::Memory], + "&str should be parsed into the known Memory variant", + ); + assert_eq!( + opts.disabled_capabilities, + vec![SessionCapability::PlanMode], + "String should be parsed into the known PlanMode variant", + ); + } + #[test] fn log_level_args_omitted_when_unset() { let opts = ClientOptions::default();