Skip to content

Commit cb93071

Browse files
MorabbinCopilot
andcommitted
Rust SDK: add typed per-session capability controls
Adds a typed `SessionCapability` enum and matching `SessionConfig` / `ResumeSessionConfig` fields plus builder methods, so callers can express "enable memory", "disable plan-mode", etc. as a per-session wire parameter rather than a spawn-time CLI flag. - `SessionCapability` is `#[non_exhaustive]`, kebab-case-serialized (via `Display` / `FromStr` / `From<&str>` / `From<String>`), and carries an `Other(String)` escape hatch for forward compatibility with capabilities the runtime adds without requiring an SDK rebuild. - `SessionConfig` and `ResumeSessionConfig` each gain `enabled_capabilities` / `disabled_capabilities` vectors and four builders: `with_enable_capability`, `with_disable_capability`, `with_enabled_capabilities`, `with_disabled_capabilities`. - `SessionConfig::into_wire` and `ResumeSessionConfig::into_wire` convert the vecs to `Option<Vec<String>>` and emit them as `enabledCapabilities` / `disabledCapabilities` in the `session.create` and `session.resume` JSON-RPC payloads. Empty vecs are serialised as `None` (field omitted). Disable wins over enable on conflict (the runtime applies enable first, then disable). - Works for every transport -- including `Transport::External` (Desktop app / shared CLI server) -- because it does not rely on CLI spawn arguments. Pairs with github/copilot-agent-runtime#8918 (per-session capability API) and github/agents#981 (Desktop missing memory capability). 10 new unit tests: 3 enum-level tests (Display / FromStr / From conversions) and 7 wire-serialisation tests in a dedicated `capability_tests` module in `types.rs` (empty omitted, single enable, single disable, bulk-replace, Other round-trip, resume empty, resume enable+disable). Pre-existing test breakage: rust/tests/session_test.rs and rust/tests/protocol_version_test.rs reference removed API methods on main and are unrelated to this change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent afbebc7 commit cb93071

4 files changed

Lines changed: 715 additions & 2 deletions

File tree

rust/README.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,105 @@ client.stop().await?;
7878

7979
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`.
8080

81+
### Session capabilities
82+
83+
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:
84+
85+
- `enabledCapabilities` -- capabilities to opt the session into (extends the `SDK_CAPABILITIES` baseline)
86+
- `disabledCapabilities` -- capabilities to opt the session out of (disable wins on overlap)
87+
- `capabilityProviders` -- capabilities this client actively *provides* (e.g. renders a canvas, drives elicitation)
88+
89+
This approach works for every transport -- including `Transport::External` (Desktop app / shared CLI server) -- because it does not rely on CLI spawn arguments.
90+
91+
> **Runtime dependency.** Per-session capability controls require
92+
> [github/copilot-agent-runtime#8918](https://github.com/github/copilot-agent-runtime/pull/8918)
93+
> or later. On older runtimes the fields are silently ignored.
94+
> Pairs with [github/agents#981](https://github.com/github/agents/issues/981)
95+
> (Desktop app missing memory capability).
96+
97+
Use `SessionConfig::with_enable_capability` / `with_disable_capability` / `with_capability_provider` (and their plural counterparts):
98+
99+
```rust,ignore
100+
use github_copilot_sdk::{SessionCapability, SessionConfig};
101+
102+
let session = client.create_session(
103+
SessionConfig::default()
104+
.with_enable_capability(SessionCapability::Memory)
105+
// Declare that this client provides the canvas renderer.
106+
.with_capability_provider(SessionCapability::CanvasRenderer),
107+
"What is 2 + 2?".into(),
108+
).await?;
109+
```
110+
111+
On resume, use the same builders on `ResumeSessionConfig`:
112+
113+
```rust,ignore
114+
use github_copilot_sdk::{ResumeSessionConfig, SessionCapability};
115+
116+
let session = client.resume_session(
117+
ResumeSessionConfig::new(session_id)
118+
.with_enable_capability(SessionCapability::Memory)
119+
.with_capability_provider(SessionCapability::Elicitation),
120+
None,
121+
).await?;
122+
```
123+
124+
**Variants:**
125+
126+
| Variant | Wire name | Description |
127+
| -------------------- | ----------------------- | ----------------------------------------------------- |
128+
| `TuiHints` | `tui-hints` | TUI keyboard shortcuts |
129+
| `PlanMode` | `plan-mode` | `[[PLAN]]` handling and plan-mode instructions |
130+
| `Memory` | `memory` | `store_memory` tool and `<memories>` system-prompt section |
131+
| `CliDocumentation` | `cli-documentation` | `fetch_copilot_cli_documentation` tool and `<self_documentation>` section |
132+
| `AskUser` | `ask-user` | `ask_user` tool for interactive clarification |
133+
| `InteractiveMode` | `interactive-mode` | Interactive-CLI identity (vs headless) |
134+
| `SystemNotifications`| `system-notifications` | Automatic batched system notifications to the agent |
135+
| `Elicitation` | `elicitation` | Elicitation prompts (confirm / select / input) |
136+
| `McpApps` | `mcp-apps` | MCP-Apps `ui://` resource passthrough (SEP-1865) |
137+
| `CanvasRenderer` | `canvas-renderer` | Host-rendered extension canvases |
138+
| `Other(String)` | *(verbatim)* | Forward-compat escape hatch for unknown future names |
139+
140+
**Disable-wins semantics.** If the same capability appears in both
141+
`enabled_capabilities` and `disabled_capabilities`, disable wins. The runtime
142+
starts from an `SDK_CAPABILITIES` baseline; enabled capabilities extend it and
143+
disabled capabilities remove from it, in that order.
144+
145+
**Capability providers vs enable/disable.** `enabledCapabilities` and
146+
`disabledCapabilities` control which server-side capabilities are active.
147+
`capabilityProviders` is different: it tells the runtime that *this client*
148+
provides a capability end-to-end (for example, renders a canvas, drives the
149+
elicitation flow). The runtime uses the provider list to negotiate the
150+
capability handshake with the model.
151+
152+
> **Note:** `requestCanvasRenderer` and `requestElicitation` are legacy field
153+
> aliases for provider registration and remain supported for backward
154+
> compatibility. The `capabilityProviders` field is the forward-looking API.
155+
156+
**Forward compatibility.** The enum is `#[non_exhaustive]` and carries an
157+
`Other(String)` variant so callers on older SDK builds can opt into
158+
capabilities that the runtime adds ahead of a new SDK release, without any
159+
recompile-blocking enum-variant additions:
160+
161+
```rust,ignore
162+
use github_copilot_sdk::{SessionCapability, SessionConfig};
163+
164+
// Opt into a capability the SDK doesn't know about yet.
165+
let config = SessionConfig::default()
166+
.with_enable_capability(SessionCapability::Other("future-cap".to_string()));
167+
```
168+
169+
`&str` and `String` implement `Into<SessionCapability>`, so you can also pass
170+
string literals directly to the builders:
171+
172+
```rust,ignore
173+
use github_copilot_sdk::SessionConfig;
174+
175+
let config = SessionConfig::default()
176+
.with_enable_capability("memory") // &str coerces to SessionCapability
177+
.with_disable_capability("plan-mode");
178+
```
179+
81180
### Session
82181

83182
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 +813,13 @@ gets to be Rust here — cross-SDK parity for these is a post-release
714813
conversation, not a release blocker. None of these are deprecated and
715814
none of them are scheduled for removal.
716815

816+
- **`SessionCapability` enum** -- typed, `#[non_exhaustive]` enum for per-session
817+
capability opt-in / opt-out / provider declaration, with an `Other(String)` escape
818+
hatch for forward compatibility. Sent via `enabledCapabilities` /
819+
`disabledCapabilities` / `capabilityProviders` on the `session.create` and
820+
`session.resume` wire calls -- works for all transports including
821+
`Transport::External`. See [Session capabilities](#session-capabilities) above.
822+
Node/Python/Go/.NET accept stringly-typed flags.
717823
- **Typed newtypes**`SessionId` and `RequestId` are `#[serde(transparent)]`
718824
newtypes around `String`, so the type system distinguishes a session
719825
identifier from an arbitrary `String` at compile time. Node/Python/Go

rust/src/lib.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,129 @@ impl OtelExporterType {
402402
}
403403
}
404404

405+
/// A named session capability sent in the `session.create` and
406+
/// `session.resume` wire payloads.
407+
///
408+
/// Capabilities gate optional CLI features (extra tools, system-prompt
409+
/// sections, host-rendered surfaces). The runtime starts from a
410+
/// hard-coded `SDK_CAPABILITIES` set; use
411+
/// [`SessionConfig::with_enable_capability`] /
412+
/// [`SessionConfig::with_disable_capability`] (and their plural
413+
/// counterparts) to opt individual sessions in or out.
414+
///
415+
/// > **Not** the same as [`SessionCapabilities`] — that struct is the
416+
/// > *runtime-negotiated* capability descriptor reported by the CLI on
417+
/// > `session.create`. [`SessionCapability`] is the *opt-in / opt-out
418+
/// > toggle name* sent with each `session.create` / `session.resume`.
419+
///
420+
/// The runtime's overlap semantics are **disable-wins**: if a capability
421+
/// appears in both the enabled and disabled lists, the disable wins.
422+
/// The SDK preserves the order callers add capabilities in so the
423+
/// resulting wire payload is deterministic.
424+
///
425+
/// The enum is `#[non_exhaustive]` and carries an [`Other`](Self::Other)
426+
/// variant so forward-compat capabilities the runtime grows ahead of an
427+
/// SDK release can still be opted into without waiting for a new
428+
/// enum variant.
429+
///
430+
/// Requires github/copilot-agent-runtime#8918 or later.
431+
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
432+
#[serde(rename_all = "kebab-case")]
433+
#[non_exhaustive]
434+
pub enum SessionCapability {
435+
/// TUI-only prompt hints (keyboard shortcuts).
436+
TuiHints,
437+
/// `[[PLAN]]` handling and plan-mode instructions.
438+
PlanMode,
439+
/// `store_memory` tool and the `<memories>` system-prompt section.
440+
Memory,
441+
/// `fetch_copilot_cli_documentation` tool plus the
442+
/// `<self_documentation>` system-prompt section.
443+
CliDocumentation,
444+
/// `ask_user` tool for interactive clarification.
445+
AskUser,
446+
/// Interactive-CLI identity (vs non-interactive / headless).
447+
InteractiveMode,
448+
/// Automatic system notifications to the agent (batched, hidden
449+
/// from the user timeline).
450+
SystemNotifications,
451+
/// Elicitation support (confirm / select / input prompts).
452+
Elicitation,
453+
/// MCP-Apps (SEP-1865) `ui://` resource passthrough.
454+
McpApps,
455+
/// Extension-provided canvases rendered by the host.
456+
CanvasRenderer,
457+
/// A capability name the SDK doesn't have a typed variant for yet.
458+
///
459+
/// Pass any kebab-case capability string here to forward it
460+
/// verbatim to the runtime.
461+
Other(String),
462+
}
463+
464+
impl SessionCapability {
465+
/// The kebab-case wire string sent in `enabledCapabilities` /
466+
/// `disabledCapabilities` on `session.create` and `session.resume`.
467+
pub fn as_str(&self) -> &str {
468+
match self {
469+
Self::TuiHints => "tui-hints",
470+
Self::PlanMode => "plan-mode",
471+
Self::Memory => "memory",
472+
Self::CliDocumentation => "cli-documentation",
473+
Self::AskUser => "ask-user",
474+
Self::InteractiveMode => "interactive-mode",
475+
Self::SystemNotifications => "system-notifications",
476+
Self::Elicitation => "elicitation",
477+
Self::McpApps => "mcp-apps",
478+
Self::CanvasRenderer => "canvas-renderer",
479+
Self::Other(name) => name.as_str(),
480+
}
481+
}
482+
}
483+
484+
impl std::fmt::Display for SessionCapability {
485+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486+
f.write_str(self.as_str())
487+
}
488+
}
489+
490+
impl std::str::FromStr for SessionCapability {
491+
type Err = std::convert::Infallible;
492+
493+
/// Parse a kebab-case capability name. Unknown names round-trip
494+
/// through [`SessionCapability::Other`] so old SDK builds stay
495+
/// useful against CLIs that add new capabilities. Always returns
496+
/// `Ok` — the error type is [`Infallible`](std::convert::Infallible).
497+
fn from_str(s: &str) -> std::result::Result<Self, std::convert::Infallible> {
498+
Ok(match s {
499+
"tui-hints" => Self::TuiHints,
500+
"plan-mode" => Self::PlanMode,
501+
"memory" => Self::Memory,
502+
"cli-documentation" => Self::CliDocumentation,
503+
"ask-user" => Self::AskUser,
504+
"interactive-mode" => Self::InteractiveMode,
505+
"system-notifications" => Self::SystemNotifications,
506+
"elicitation" => Self::Elicitation,
507+
"mcp-apps" => Self::McpApps,
508+
"canvas-renderer" => Self::CanvasRenderer,
509+
other => Self::Other(other.to_owned()),
510+
})
511+
}
512+
}
513+
514+
impl From<&str> for SessionCapability {
515+
fn from(s: &str) -> Self {
516+
// FromStr::from_str is Infallible — unwrap is safe.
517+
s.parse()
518+
.expect("SessionCapability::from_str is Infallible")
519+
}
520+
}
521+
522+
impl From<String> for SessionCapability {
523+
fn from(s: String) -> Self {
524+
s.as_str().into()
525+
}
526+
}
527+
405528
/// OpenTelemetry configuration forwarded to the spawned GitHub Copilot CLI
406529
/// process.
407530
///
@@ -2379,6 +2502,47 @@ mod tests {
23792502
assert_eq!(Client::remote_args(&opts), vec!["--remote".to_string()]);
23802503
}
23812504

2505+
#[test]
2506+
fn session_capability_round_trips_via_str() {
2507+
for cap in [
2508+
SessionCapability::TuiHints,
2509+
SessionCapability::PlanMode,
2510+
SessionCapability::Memory,
2511+
SessionCapability::CliDocumentation,
2512+
SessionCapability::AskUser,
2513+
SessionCapability::InteractiveMode,
2514+
SessionCapability::SystemNotifications,
2515+
SessionCapability::Elicitation,
2516+
SessionCapability::McpApps,
2517+
SessionCapability::CanvasRenderer,
2518+
] {
2519+
let s = cap.to_string();
2520+
let parsed: SessionCapability = s.parse().unwrap();
2521+
assert_eq!(parsed, cap, "round-trip failed for {s}");
2522+
}
2523+
}
2524+
2525+
#[test]
2526+
fn session_capability_from_str_falls_back_to_other_for_unknown_names() {
2527+
let parsed: SessionCapability = "brand-new-cap".parse().unwrap();
2528+
assert_eq!(
2529+
parsed,
2530+
SessionCapability::Other("brand-new-cap".to_string())
2531+
);
2532+
assert_eq!(parsed.as_str(), "brand-new-cap");
2533+
}
2534+
2535+
#[test]
2536+
fn session_capability_into_from_str_and_string() {
2537+
let from_str: SessionCapability = "memory".into();
2538+
let from_string: SessionCapability = "memory".to_string().into();
2539+
assert_eq!(from_str, SessionCapability::Memory);
2540+
assert_eq!(from_string, SessionCapability::Memory);
2541+
// Unknown names go to Other
2542+
let other: SessionCapability = "future-cap".into();
2543+
assert_eq!(other, SessionCapability::Other("future-cap".to_string()));
2544+
}
2545+
23822546
#[test]
23832547
fn log_level_args_omitted_when_unset() {
23842548
let opts = ClientOptions::default();

0 commit comments

Comments
 (0)