From 9aba1f2e9807adf40b15939e5ead812534f0a66a Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 05:41:34 -0700 Subject: [PATCH 01/14] Canvas extensibility V1 (Rust snapshot from github-app vendor) This is a hand-applied snapshot of the Rust SDK changes for canvas extensibility V1, lifted from a vendored copy in the github-app repository (which had been edited in-place while this upstream branch was not yet ready). IMPORTANT CAVEAT: In this repo, most of the Rust types here are normally generated from TypeScript schemas via codegen. The corresponding TypeScript schema changes were authored on another agent's branch (0ebfff40f3) that has not been pushed to origin. Specifically, that branch contains: - manifest \`agentActions\` - \`session.canvas.*\` host SDK - canvas-level \`inputSchema\` - \`canvas_input_invalid\` validation - \`actionId\` -> \`actionName\` rename - removal of \`requires\` / \`extensionRequires\` Because of this, this branch is a Rust-only snapshot of the desired end state, not the full TypeScript-driven implementation. Whoever picks this up will need to either: (a) merge the TypeScript work from the orchestrator's branch and re-run codegen, or (b) treat this Rust diff as a spec and re-derive the TypeScript schema from it. Build status on this snapshot (cargo, in rust/): - \`cargo check\`: passes - \`cargo test --no-run\`: fails to compile some test binaries (protocol_version_test, session_test). These are pre-existing integration tests that rely on APIs not yet adjusted to match this snapshot (e.g. \`Client::from_streams_with_trace_provider\`). Intentionally not fixed here -- the goal of this branch is to preserve the snapshot, not to make it green standalone. Branched off 477834f8 ("Publish .snupkg symbols package to NuGet.org (#1345)") -- the SHA the vendor was synced from -- rather than current main, so the diff applies cleanly. Rebase onto main as needed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/generated/api_types.rs | 427 ++++++++++++++++++++++++++- rust/src/generated/rpc.rs | 161 ++++++++++ rust/src/generated/session_events.rs | 21 ++ rust/src/handler.rs | 121 +++++++- rust/src/lib.rs | 6 + rust/src/session.rs | 63 +++- rust/src/tool.rs | 35 +++ rust/src/types.rs | 292 +++++++++++++++++- rust/tests/api_types_test.rs | 1 + 9 files changed, 1107 insertions(+), 20 deletions(-) diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 271afb62c..50140f779 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -199,12 +199,24 @@ pub mod rpc_methods { pub const SESSION_MCP_OAUTH_LOGIN: &str = "session.mcp.oauth.login"; /// `session.plugins.list` pub const SESSION_PLUGINS_LIST: &str = "session.plugins.list"; + /// `session.canvas.open` + pub const SESSION_CANVAS_OPEN: &str = "session.canvas.open"; + /// `session.canvas.focus` + pub const SESSION_CANVAS_FOCUS: &str = "session.canvas.focus"; + /// `session.canvas.close` + pub const SESSION_CANVAS_CLOSE: &str = "session.canvas.close"; + /// `session.canvas.reload` + pub const SESSION_CANVAS_RELOAD: &str = "session.canvas.reload"; + /// `session.canvas.invokeAction` + pub const SESSION_CANVAS_INVOKEACTION: &str = "session.canvas.invokeAction"; /// `session.options.update` pub const SESSION_OPTIONS_UPDATE: &str = "session.options.update"; /// `session.lsp.initialize` pub const SESSION_LSP_INITIALIZE: &str = "session.lsp.initialize"; /// `session.extensions.list` pub const SESSION_EXTENSIONS_LIST: &str = "session.extensions.list"; + /// `session.extensions.discoverCanvases` + pub const SESSION_EXTENSIONS_DISCOVERCANVASES: &str = "session.extensions.discoverCanvases"; /// `session.extensions.enable` pub const SESSION_EXTENSIONS_ENABLE: &str = "session.extensions.enable"; /// `session.extensions.disable` @@ -1164,17 +1176,22 @@ pub struct ExecuteCommandResult { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Extension { - /// Source-qualified ID (e.g., 'project:my-ext', 'user:auth-helper') + /// Full extension ID: `..` (or `.` when + /// the manifest has no publisher) pub id: String, - /// Extension name (directory name) + /// Extension name (manifest name or directory name) pub name: String, /// Process ID if the extension is running #[serde(skip_serializing_if = "Option::is_none")] pub pid: Option, - /// Discovery source: project (.github/extensions/) or user (~/.copilot/extensions/) + /// Discovery source: project (.github/extensions/), user (~/.copilot/extensions/), + /// or host registration pub source: ExtensionSource, - /// Current status: running, disabled, failed, or starting + /// Current status: running, disabled, failed, starting, or unavailable pub status: ExtensionStatus, + /// Reason the extension is unavailable, if known + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable_reason: Option, } /// Extensions discovered for the session, with their current status. @@ -1192,8 +1209,298 @@ pub struct ExtensionList { pub extensions: Vec, } -/// Source-qualified extension identifier to disable for the session. +/// Request parameters for `session.canvas.invokeAction`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInvokeActionRequest { + /// Canvas instance id returned from `session.canvas.open` or `open_canvas`. + pub instance_id: String, + /// Extension-defined action name declared in the canvas's `agentActions[]` + /// manifest entry. The lifecycle verbs (`canvas.open` / `canvas.focus` / + /// `canvas.close` / `canvas.reload`) are reserved and rejected. + pub action_name: String, + /// Optional input forwarded to the extension's action handler. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, +} + +/// Result returned from `session.canvas.invokeAction`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInvokeActionResult { + /// Provider's action result payload (may be absent for fire-and-forget + /// actions). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, +} + +/// Request parameters for `session.canvas.open`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasOpenRequest { + /// Canonical extension id that owns the canvas (e.g. `host.github.markdown`). + pub extension_id: String, + /// Canvas contribution id declared in the extension's manifest. + pub canvas_id: String, + /// Optional opaque payload forwarded to the extension's `canvas.open` + /// handler. Validated against the canvas's manifest-declared `inputSchema` + /// (when present) at the dispatch boundary; mismatches return the + /// `canvas_input_invalid` error code. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, +} + +/// Result returned from `session.canvas.open`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasOpenResult { + /// Canvas instance id; pass to focus/close/reload or invokeAction. + pub instance_id: String, + /// URL the host should render. Absent for native (hosted-in-app) canvases. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// Request parameters for `session.canvas.focus`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasFocusRequest { + /// Canvas instance id returned from `session.canvas.open` or `open_canvas`. + pub instance_id: String, +} + +/// Request parameters for `session.canvas.close`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasCloseRequest { + /// Canvas instance id returned from `session.canvas.open` or `open_canvas`. + pub instance_id: String, +} + +/// Request parameters for `session.canvas.reload`. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasReloadRequest { + /// Canvas instance id returned from `session.canvas.open` or `open_canvas`. + pub instance_id: String, +} + +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvasesImplementationNative { + /// Provider ID registered via hostExtension.invoke + pub id: String, +} + +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvasesImplementationUrl { + /// Optional provider ID; ignored by the runtime + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +/// Per-canvas implementation routing. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum ExtensionsDiscoverCanvasesImplementation { + /// Host renders the canvas in-process + Native(ExtensionsDiscoverCanvasesImplementationNative), + /// Provider returns a URL from its open handler + Url(ExtensionsDiscoverCanvasesImplementationUrl), +} + +/// Expected canvas.open result shape when known +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvasesOpenResult { + /// Expected open result category + pub kind: ExtensionsDiscoverCanvasesOpenKind, + /// Whether canvas.open must return a URL + #[serde(skip_serializing_if = "Option::is_none")] + pub url_required: Option, +} + +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvases { + /// Backing storage metadata from the manifest + #[serde(default)] + pub backing: HashMap, + /// Canvas contribution ID + pub canvas_id: String, + /// Command metadata from the manifest + #[serde(default)] + pub commands: Vec, + /// Canvas description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Human-readable canvas display name + pub display_name: String, + /// Extension description + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_description: Option, + /// Human-readable extension display name + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_display_name: Option, + /// Full extension ID that owns the canvas + pub extension_id: String, + /// Extension package name + pub extension_name: String, + /// File association metadata from the manifest + #[serde(default)] + pub file_associations: Vec, + /// Per-canvas implementation routing + #[serde(skip_serializing_if = "Option::is_none")] + pub implementation: Option, + /// How the extension implementation is invoked + #[serde(skip_serializing_if = "Option::is_none")] + pub implementation_kind: Option, + /// Expected canvas.open result shape when known + #[serde(skip_serializing_if = "Option::is_none")] + pub open_result: Option, + /// Presentation metadata from the manifest + #[serde(default)] + pub presentation: HashMap, + /// Qualified canvas ID + pub qualified_id: String, + /// Runtime metadata from the manifest + #[serde(default)] + pub runtime: HashMap, + /// Extension discovery source + pub source: ExtensionsDiscoverCanvasesSource, + /// Parent extension status + pub status: ExtensionsDiscoverCanvasesStatus, + /// Canvas title + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Toolbar metadata from the manifest + #[serde(default)] + pub toolbar: Vec, + /// Reason the canvas is unavailable, if known + #[serde(skip_serializing_if = "Option::is_none")] + pub unavailable_reason: Option, +} + +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvasesRequest { + /// Optional canvas contribution ID or qualified canvas ID + #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, + /// Optional full extension ID to filter by + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_id: Option, + /// When false, omit unavailable canvases + #[serde(skip_serializing_if = "Option::is_none")] + pub include_unavailable: Option, +} + +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. /// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtensionsDiscoverCanvasesResult { + /// Canvas contributions discovered by the runtime + pub canvases: Vec, +} + ///
/// /// **Experimental.** This type is part of an experimental wire-protocol surface @@ -1203,11 +1510,10 @@ pub struct ExtensionList { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExtensionsDisableRequest { - /// Source-qualified extension ID to disable + /// Full extension ID to disable pub id: String, } -/// Source-qualified extension identifier to enable for the session. /// ///
/// @@ -1218,7 +1524,7 @@ pub struct ExtensionsDisableRequest { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExtensionsEnableRequest { - /// Source-qualified extension ID to enable + /// Full extension ID to enable pub id: String, } @@ -8893,19 +9199,30 @@ pub enum EventsCursorStatus { /// and may change or be removed in future SDK or CLI releases. /// ///
+/// Discovery source: project (.github/extensions/), user (~/.copilot/extensions/), +/// or host registration +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ExtensionSource { #[serde(rename = "project")] Project, #[serde(rename = "user")] User, + #[serde(rename = "host")] + Host, /// Unknown variant for forward compatibility. #[default] #[serde(other)] Unknown, } -/// Current status: running, disabled, failed, or starting +/// Current status: running, disabled, failed, starting, or unavailable /// ///
/// @@ -8923,6 +9240,98 @@ pub enum ExtensionStatus { Failed, #[serde(rename = "starting")] Starting, + #[serde(rename = "unavailable")] + Unavailable, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// How the extension implementation is invoked +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExtensionsDiscoverCanvasesImplementationKind { + #[serde(rename = "path")] + Path, + #[serde(rename = "host")] + Host, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Expected open result category +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExtensionsDiscoverCanvasesOpenKind { + #[serde(rename = "url")] + Url, + #[serde(rename = "hosted")] + Hosted, + #[serde(rename = "unknown")] + UnknownValue, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Extension discovery source +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExtensionsDiscoverCanvasesSource { + #[serde(rename = "project")] + Project, + #[serde(rename = "user")] + User, + #[serde(rename = "host")] + Host, + /// Unknown variant for forward compatibility. + #[default] + #[serde(other)] + Unknown, +} + +/// Parent extension status +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExtensionsDiscoverCanvasesStatus { + #[serde(rename = "running")] + Running, + #[serde(rename = "disabled")] + Disabled, + #[serde(rename = "failed")] + Failed, + #[serde(rename = "starting")] + Starting, + #[serde(rename = "unavailable")] + Unavailable, /// Unknown variant for forward compatibility. #[default] #[serde(other)] diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index 2d18b816b..5256524f7 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -1099,6 +1099,13 @@ impl<'a> SessionRpc<'a> { } } + /// `session.canvas.*` sub-namespace. + pub fn canvas(&self) -> SessionRpcCanvas<'a> { + SessionRpcCanvas { + session: self.session, + } + } + /// `session.commands.*` sub-namespace. pub fn commands(&self) -> SessionRpcCommands<'a> { SessionRpcCommands { @@ -1751,6 +1758,132 @@ impl<'a> SessionRpcCommands<'a> { } } +/// `session.canvas.*` RPCs. +#[derive(Clone, Copy)] +pub struct SessionRpcCanvas<'a> { + pub(crate) session: &'a Session, +} + +impl<'a> SessionRpcCanvas<'a> { + /// Opens a canvas instance for the given extension/canvas pair. Returns + /// the registry instance id (and the URL the host should render, for + /// URL-kind canvases). + /// + /// Wire method: `session.canvas.open`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// Pin both the SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn open(&self, params: CanvasOpenRequest) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_CANVAS_OPEN, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } + + /// Focuses a previously opened canvas instance. + /// + /// Wire method: `session.canvas.focus`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// Pin both the SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn focus(&self, params: CanvasFocusRequest) -> Result<(), Error> { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_CANVAS_FOCUS, Some(wire_params)) + .await?; + Ok(()) + } + + /// Closes a previously opened canvas instance. + /// + /// Wire method: `session.canvas.close`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// Pin both the SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn close(&self, params: CanvasCloseRequest) -> Result<(), Error> { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_CANVAS_CLOSE, Some(wire_params)) + .await?; + Ok(()) + } + + /// Reloads a previously opened canvas instance. + /// + /// Wire method: `session.canvas.reload`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// Pin both the SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn reload(&self, params: CanvasReloadRequest) -> Result<(), Error> { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_CANVAS_RELOAD, Some(wire_params)) + .await?; + Ok(()) + } + + /// Invokes a declared `agentActions[]` entry against an open canvas + /// instance. Lifecycle verbs (`canvas.*`) are reserved and rejected by + /// the runtime — use `session.canvas.{open|focus|close|reload}` for + /// those. + /// + /// Wire method: `session.canvas.invokeAction`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol + /// surface and may change or be removed in future SDK or CLI releases. + /// Pin both the SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn invoke_action( + &self, + params: CanvasInvokeActionRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call(rpc_methods::SESSION_CANVAS_INVOKEACTION, Some(wire_params)) + .await?; + Ok(serde_json::from_value(_value)?) + } +} + /// `session.eventLog.*` RPCs. #[derive(Clone, Copy)] pub struct SessionRpcEventLog<'a> { @@ -1918,6 +2051,34 @@ impl<'a> SessionRpcExtensions<'a> { Ok(serde_json::from_value(_value)?) } + /// Calls `session.extensions.discoverCanvases`. + /// + /// Wire method: `session.extensions.discoverCanvases`. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn discover_canvases( + &self, + params: ExtensionsDiscoverCanvasesRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_EXTENSIONS_DISCOVERCANVASES, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } + /// Enables an extension for the session. /// /// Wire method: `session.extensions.enable`. diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 6d9237b92..e2b96f7b0 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -1948,6 +1948,12 @@ pub struct PermissionRequestCustomTool { pub tool_description: String, /// Name of the custom tool pub tool_name: String, + /// Extension operation namespace for routing and policy context + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Canvas contribution ID for canvas-scoped custom tools + #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, } /// Hook confirmation permission request @@ -2138,6 +2144,12 @@ pub struct PermissionPromptRequestCustomTool { pub tool_description: String, /// Name of the custom tool pub tool_name: String, + /// Extension operation namespace for routing and policy context + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Canvas contribution ID for canvas-scoped custom tools + #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, } /// Path access permission prompt @@ -2595,6 +2607,15 @@ pub struct ExternalToolRequestedData { pub tool_call_id: String, /// Name of the external tool to invoke pub tool_name: String, + /// Extension ID for dispatcher-routed extension tools + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_id: Option, + /// Optional namespace for the external tool. + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Canvas ID for dispatcher-routed canvas extension tools + #[serde(skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, /// W3C Trace Context traceparent header for the execute_tool span #[serde(skip_serializing_if = "Option::is_none")] pub traceparent: Option, diff --git a/rust/src/handler.rs b/rust/src/handler.rs index 565b09d56..cbcfc6926 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -8,8 +8,9 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use crate::types::{ - ElicitationRequest, ElicitationResult, ExitPlanModeData, PermissionRequestData, RequestId, - SessionEvent, SessionId, ToolInvocation, ToolResult, + ElicitationRequest, ElicitationResult, ExitPlanModeData, HostedExtensionRequest, + HostedExtensionResponse, PermissionRequestData, RequestId, SessionEvent, SessionId, + ToolInvocation, ToolResult, }; /// Events dispatched by the SDK session event loop to the handler. @@ -59,6 +60,15 @@ pub enum HandlerEvent { invocation: ToolInvocation, }, + /// The runtime requests a hosted extension action or lifecycle call. + /// Return `HandlerResponse::HostedExtension(..)`. + HostedExtension { + /// The requesting session. + session_id: SessionId, + /// Host-backed request envelope from the runtime. + request: HostedExtensionRequest, + }, + /// The CLI broadcasts an elicitation request for the provider to handle. /// Return `HandlerResponse::Elicitation(..)`. ElicitationRequest { @@ -106,6 +116,8 @@ pub enum HandlerResponse { UserInput(Option), /// Result of a tool execution. ToolResult(ToolResult), + /// Structured hosted extension response. + HostedExtension(HostedExtensionResponse), /// Elicitation result (accept/decline/cancel with optional form data). Elicitation(ElicitationResult), /// Exit plan mode decision. @@ -317,6 +329,12 @@ pub trait SessionHandler: Send + Sync + 'static { HandlerEvent::ExternalTool { invocation } => { HandlerResponse::ToolResult(self.on_external_tool(invocation).await) } + HandlerEvent::HostedExtension { + session_id, + request, + } => HandlerResponse::HostedExtension( + self.on_hosted_extension(session_id, request).await, + ), HandlerEvent::ElicitationRequest { session_id, request_id, @@ -395,6 +413,23 @@ pub trait SessionHandler: Send + Sync + 'static { }) } + /// The runtime is invoking a hosted extension implementation. + /// + /// Default: return a structured error so the runtime can surface the failure. + async fn on_hosted_extension( + &self, + _session_id: SessionId, + request: HostedExtensionRequest, + ) -> HostedExtensionResponse { + HostedExtensionResponse::error( + "hosted_extension_unavailable", + format!( + "No handler registered for hosted extension '{}'", + request.implementation_id + ), + ) + } + /// The CLI is requesting an elicitation (structured form / URL prompt). /// /// Default: cancel. @@ -492,6 +527,15 @@ impl SessionHandler for NoopHandler { } HandlerEvent::UserInput { .. } => HandlerResponse::UserInput(None), HandlerEvent::ExternalTool { .. } => HandlerResponse::NoResult, + HandlerEvent::HostedExtension { request, .. } => { + HandlerResponse::HostedExtension(HostedExtensionResponse::error( + "hosted_extension_unavailable", + format!( + "No handler registered for hosted extension '{}'", + request.implementation_id + ), + )) + } HandlerEvent::ElicitationRequest { .. } => { HandlerResponse::Elicitation(ElicitationResult { action: "cancel".to_string(), @@ -606,6 +650,9 @@ mod tests { session_id: SessionId::from("s1".to_string()), tool_call_id: "tc1".to_string(), tool_name: "missing".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: Value::Null, traceparent: None, tracestate: None, @@ -622,6 +669,73 @@ mod tests { } } + struct HostedExtensionHandler; + + #[async_trait] + impl SessionHandler for HostedExtensionHandler { + async fn on_hosted_extension( + &self, + session_id: SessionId, + request: HostedExtensionRequest, + ) -> HostedExtensionResponse { + HostedExtensionResponse::Success(crate::types::HostedExtensionSuccessResponse { + ok: true, + result: serde_json::json!({ + "sessionId": session_id, + "implementationId": request.implementation_id, + }), + }) + } + } + + #[tokio::test] + async fn default_on_hosted_extension_returns_structured_error() { + let h = DenyAllHandler; + let resp = h + .on_event(HandlerEvent::HostedExtension { + session_id: SessionId::from("s1".to_string()), + request: HostedExtensionRequest { + id: "request-1".to_string(), + implementation_id: "github-app.markdown".to_string(), + method: "canvas.action.invoke".to_string(), + params: Value::Null, + }, + }) + .await; + + match resp { + HandlerResponse::HostedExtension(HostedExtensionResponse::Error(error)) => { + assert_eq!(error.error.code, "hosted_extension_unavailable"); + assert!(error.error.message.contains("github-app.markdown")); + } + other => panic!("unexpected response: {other:?}"), + } + } + + #[tokio::test] + async fn hosted_extension_dispatches_via_default_on_event() { + let h = HostedExtensionHandler; + let resp = h + .on_event(HandlerEvent::HostedExtension { + session_id: SessionId::from("s1".to_string()), + request: HostedExtensionRequest { + id: "request-1".to_string(), + implementation_id: "github-app.markdown".to_string(), + method: "canvas.action.invoke".to_string(), + params: Value::Null, + }, + }) + .await; + + match resp { + HandlerResponse::HostedExtension(HostedExtensionResponse::Success(success)) => { + assert_eq!(success.result["sessionId"], "s1"); + assert_eq!(success.result["implementationId"], "github-app.markdown"); + } + other => panic!("unexpected response: {other:?}"), + } + } + #[tokio::test] async fn noop_handler_leaves_permission_and_external_tool_pending() { let h = NoopHandler; @@ -643,6 +757,9 @@ mod tests { session_id: SessionId::from("s1".to_string()), tool_call_id: "tc1".to_string(), tool_name: "manual".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: Value::Null, traceparent: None, tracestate: None, diff --git a/rust/src/lib.rs b/rust/src/lib.rs index abb1a72a4..6d219a48d 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -85,6 +85,10 @@ pub enum Error { code: i32, /// Human-readable error message. message: String, + /// Optional structured error data carried alongside the response. + /// Used by the runtime to convey extension-specific error codes + /// (e.g. `canvas_input_invalid`) and structured details. + data: Option, }, /// Session-scoped error (not found, agent error, timeout, etc.). @@ -1528,6 +1532,7 @@ impl Client { return Err(Error::Rpc { code: err.code, message: err.message, + data: err.data, }); } Ok(response.result.unwrap_or(serde_json::Value::Null)) @@ -2032,6 +2037,7 @@ mod tests { let err = Error::Rpc { code: -1, message: "bad".into(), + data: None, }; assert!(!err.is_transport_failure()); } diff --git a/rust/src/session.rs b/rust/src/session.rs index d533dbc44..09d62e616 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -28,11 +28,11 @@ use crate::trace_context::inject_trace_context; use crate::transforms::SystemMessageTransform; use crate::types::{ CommandContext, CommandDefinition, CommandHandler, CreateSessionResult, ElicitationRequest, - ElicitationResult, ExitPlanModeData, GetMessagesResponse, InputOptions, MessageOptions, - PermissionRequestData, RequestId, ResumeSessionConfig, SectionOverride, SessionCapabilities, - SessionConfig, SessionEvent, SessionId, SetModelOptions, SystemMessageConfig, ToolInvocation, - ToolResult, ToolResultExpanded, ToolResultResponse, TraceContext, - ensure_attachment_display_names, + ElicitationResult, ExitPlanModeData, GetMessagesResponse, HostedExtensionInvokeRequest, + InputOptions, MessageOptions, PermissionRequestData, RequestId, ResumeSessionConfig, + SectionOverride, SessionCapabilities, SessionConfig, SessionEvent, SessionId, SetModelOptions, + SystemMessageConfig, ToolInvocation, ToolResult, ToolResultExpanded, ToolResultResponse, + TraceContext, ensure_attachment_display_names, }; use crate::{Client, Error, JsonRpcResponse, SessionError, SessionEventNotification, error_codes}; @@ -1343,6 +1343,8 @@ async fn handle_notification( PermissionRequestData { kind: None, tool_call_id: None, + namespace: None, + canvas_id: None, extra: notification.event.data.clone(), } }); @@ -1475,6 +1477,9 @@ async fn handle_notification( session_id: sid.clone(), tool_call_id: data.tool_call_id, tool_name: data.tool_name, + extension_id: data.extension_id, + namespace: data.namespace, + canvas_id: data.canvas_id, arguments: data .arguments .unwrap_or(Value::Object(serde_json::Map::new())), @@ -1797,6 +1802,52 @@ async fn handle_request( let _ = client.send_response(&rpc_response).await; } + "hostExtension.invoke" => { + let host_request: HostedExtensionInvokeRequest = + match request.params.as_ref().and_then(|p| { + serde_json::from_value::(p.clone()).ok() + }) { + Some(host_request) => host_request, + None => { + let _ = send_error_response( + client, + request.id, + error_codes::INVALID_PARAMS, + "invalid hostExtension.invoke params", + ) + .await; + return; + } + }; + + let handler_start = Instant::now(); + let response = handler + .on_event(HandlerEvent::HostedExtension { + session_id: host_request.session_id, + request: host_request.request, + }) + .await; + tracing::debug!( + elapsed_ms = handler_start.elapsed().as_millis(), + session_id = %sid, + "SessionHandler::on_hosted_extension dispatch" + ); + let result = match response { + HandlerResponse::HostedExtension(response) => serde_json::json!(response), + _ => serde_json::json!(crate::types::HostedExtensionResponse::error( + "unexpected_handler_response", + "Unexpected handler response for hostExtension.invoke", + )), + }; + let rpc_response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id, + result: Some(result), + error: None, + }; + let _ = client.send_response(&rpc_response).await; + } + "userInput.request" => { let params = request.params.as_ref(); let Some(question) = params @@ -1965,6 +2016,8 @@ async fn handle_request( serde_json::from_value(raw_params.clone()).unwrap_or(PermissionRequestData { kind: None, tool_call_id: None, + namespace: None, + canvas_id: None, extra: raw_params, }); diff --git a/rust/src/tool.rs b/rust/src/tool.rs index 3342f4b9f..075ea5f33 100644 --- a/rust/src/tool.rs +++ b/rust/src/tool.rs @@ -202,6 +202,7 @@ pub fn convert_mcp_call_tool_result(value: &serde_json::Value) -> Option()), /// instructions: None, @@ -464,6 +465,7 @@ mod tests { Tool { name: "echo".to_string(), namespaced_name: None, + namespace: None, description: "Echo the input".to_string(), parameters: tool_parameters(serde_json::json!({"type": "object"})), instructions: None, @@ -655,6 +657,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "echo".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({"msg": "hello"}), traceparent: None, tracestate: None, @@ -695,6 +700,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "weather".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({"city": "Seattle"}), traceparent: None, tracestate: None, @@ -714,6 +722,7 @@ mod tests { Tool { name: "tool_a".to_string(), namespaced_name: None, + namespace: None, description: "A".to_string(), parameters: HashMap::new(), instructions: None, @@ -733,6 +742,7 @@ mod tests { Tool { name: "tool_b".to_string(), namespaced_name: None, + namespace: None, description: "B".to_string(), parameters: HashMap::new(), instructions: None, @@ -758,6 +768,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "tool_b".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({}), traceparent: None, tracestate: None, @@ -794,6 +807,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "unknown".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({}), traceparent: None, tracestate: None, @@ -815,6 +831,7 @@ mod tests { Tool { name: "bad_tool".to_string(), namespaced_name: None, + namespace: None, description: "Always fails".to_string(), parameters: HashMap::new(), instructions: None, @@ -826,6 +843,7 @@ mod tests { Err(Error::Rpc { code: -1, message: "intentional failure".to_string(), + data: None, }) } } @@ -840,6 +858,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "bad_tool".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({}), traceparent: None, tracestate: None, @@ -897,6 +918,7 @@ mod tests { Tool { name: "ok_tool".to_string(), namespaced_name: None, + namespace: None, description: "ok".to_string(), parameters: HashMap::new(), instructions: None, @@ -920,6 +942,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "ok_tool".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({}), traceparent: None, tracestate: None, @@ -971,6 +996,7 @@ mod tests { Tool { name: "get_weather".to_string(), namespaced_name: None, + namespace: None, description: "Get weather for a city".to_string(), parameters: tool_parameters(schema_for::()), instructions: None, @@ -1005,6 +1031,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "get_weather".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({"city": "Seattle", "unit": "celsius"}), traceparent: None, tracestate: None, @@ -1024,6 +1053,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "get_weather".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({"wrong_field": 42}), traceparent: None, tracestate: None, @@ -1049,6 +1081,9 @@ mod tests { session_id: SessionId::from("s1"), tool_call_id: "tc1".to_string(), tool_name: "get_weather".to_string(), + extension_id: None, + namespace: None, + canvas_id: None, arguments: serde_json::json!({"city": "Portland"}), traceparent: None, tracestate: None, diff --git a/rust/src/types.rs b/rust/src/types.rs index 70f0c16b7..1d8c8956e 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -308,6 +308,12 @@ pub struct Tool { /// for MCP tools). #[serde(default, skip_serializing_if = "Option::is_none")] pub namespaced_name: Option, + /// Optional runtime namespace for this external tool definition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Canvas contribution ID for canvas-scoped external tool definitions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, /// Description of what the tool does. #[serde(default)] pub description: String, @@ -368,6 +374,18 @@ impl Tool { self } + /// Set the runtime namespace for this external tool definition. + pub fn with_namespace(mut self, namespace: impl Into) -> Self { + self.namespace = Some(namespace.into()); + self + } + + /// Set the canvas contribution ID for canvas-scoped external tool definitions. + pub fn with_canvas_id(mut self, canvas_id: impl Into) -> Self { + self.canvas_id = Some(canvas_id.into()); + self + } + /// Set the human-readable description of what the tool does. pub fn with_description(mut self, description: impl Into) -> Self { self.description = description.into(); @@ -965,6 +983,110 @@ fn default_env_value_mode() -> String { "direct".into() } +/// Temporary host-provided extension registration payload for the runtime canvas POC. +/// +/// This mirrors the runtime stdio contract and should be replaced by the upstream +/// `github/copilot-sdk` generated type once protocol parity lands there. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[allow(missing_docs)] +pub struct ExtensionRegistrationConfig { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extension_roots: Vec, + /// Bare extension manifests registered by the host. Each entry is a + /// `copilot-extension.json` payload (not wrapped); per-canvas + /// `contributes.canvases[].implementation: { kind, id }` routes + /// `canvas.action.invoke` dispatch. + #[serde( + default, + rename = "hostExtensions", + skip_serializing_if = "Vec::is_empty" + )] + pub host_extensions: Vec, +} + +/// Host-advertised runtime capabilities. The runtime gates corresponding +/// agent tools based on what the host implements. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[allow(missing_docs)] +pub struct HostCapabilitiesConfig { + /// When `true`, the runtime exposes the canvas agent tools + /// (`open_canvas`, `focus_canvas`, `close_canvas`, `reload_canvas`, + /// `discover_canvases`) and adds the `canvas-host` session capability. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas: Option, +} + +/// Server-to-client hosted extension callback request. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[allow(missing_docs)] +pub struct HostedExtensionInvokeRequest { + pub session_id: SessionId, + pub request: HostedExtensionRequest, +} + +/// Hosted extension action/lifecycle request envelope. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[allow(missing_docs)] +pub struct HostedExtensionRequest { + pub id: String, + pub implementation_id: String, + pub method: String, + pub params: Value, +} + +/// Structured response envelope for hosted extension callbacks. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +#[allow(missing_docs)] +pub enum HostedExtensionResponse { + Success(HostedExtensionSuccessResponse), + Error(HostedExtensionErrorResponse), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[allow(missing_docs)] +pub struct HostedExtensionSuccessResponse { + pub ok: bool, + pub result: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[allow(missing_docs)] +pub struct HostedExtensionErrorResponse { + pub ok: bool, + pub error: HostedExtensionError, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[allow(missing_docs)] +pub struct HostedExtensionError { + pub code: String, + pub message: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub required_capabilities: Vec, +} + +impl HostedExtensionResponse { + #[allow(missing_docs)] + pub fn error(code: impl Into, message: impl Into) -> Self { + Self::Error(HostedExtensionErrorResponse { + ok: false, + error: HostedExtensionError { + code: code.into(), + message: message.into(), + required_capabilities: Vec::new(), + }, + }) + } +} + /// Configuration for creating a new session via the `session.create` RPC. /// /// All fields are optional — the CLI applies sensible defaults. @@ -1165,6 +1287,15 @@ pub struct SessionConfig { /// associated [`CommandHandler`] is called when executed. #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] pub commands: Option>, + /// Host-provided extension registrations for the temporary canvas POC. + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_registrations: Option, + /// Host-advertised runtime capabilities (gates canvas agent tools, etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub host_capabilities: Option, + /// Ask the runtime to route hosted extension callbacks over this SDK connection. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_host_extension: 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. @@ -1233,6 +1364,8 @@ impl std::fmt::Debug for SessionConfig { &self.include_sub_agent_streaming_events, ) .field("commands", &self.commands) + .field("extension_registrations", &self.extension_registrations) + .field("request_host_extension", &self.request_host_extension) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -1290,6 +1423,9 @@ impl Default for SessionConfig { cloud: None, include_sub_agent_streaming_events: None, commands: None, + extension_registrations: None, + host_capabilities: None, + request_host_extension: None, session_fs_provider: None, handler: None, hooks_handler: None, @@ -1743,6 +1879,15 @@ pub struct ResumeSessionConfig { /// so the resume payload re-supplies the registration. #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] pub commands: Option>, + /// Host-provided extension registrations for the temporary canvas POC. + #[serde(skip_serializing_if = "Option::is_none")] + pub extension_registrations: Option, + /// Host-advertised runtime capabilities (gates canvas agent tools, etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub host_capabilities: Option, + /// Ask the runtime to route hosted extension callbacks over this SDK connection. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_host_extension: Option, /// Custom session filesystem provider. Required on resume when the /// [`Client`](crate::Client) was started with /// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs). @@ -1814,6 +1959,8 @@ impl std::fmt::Debug for ResumeSessionConfig { &self.include_sub_agent_streaming_events, ) .field("commands", &self.commands) + .field("extension_registrations", &self.extension_registrations) + .field("request_host_extension", &self.request_host_extension) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -1870,6 +2017,9 @@ impl ResumeSessionConfig { remote_session: None, include_sub_agent_streaming_events: None, commands: None, + extension_registrations: None, + host_capabilities: None, + request_host_extension: None, session_fs_provider: None, disable_resume: None, continue_pending_work: None, @@ -2834,6 +2984,15 @@ pub struct ToolInvocation { pub tool_call_id: String, /// Name of the tool being invoked. pub tool_name: String, + /// Extension ID for dispatcher-routed extension tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extension_id: Option, + /// Optional namespace for the tool invocation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Canvas ID for dispatcher-routed canvas extension tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, /// Tool arguments as JSON. pub arguments: Value, /// W3C Trace Context `traceparent` header propagated from the CLI's @@ -3194,6 +3353,12 @@ pub struct PermissionRequestData { /// to a specific tool invocation. #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, + /// Optional namespace for runtime-scoped tool permission requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Optional canvas id for canvas-scoped permission requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canvas_id: Option, /// The full permission request params from the CLI. The shape varies by /// permission type and CLI version, so we preserve it as `Value`. #[serde(flatten)] @@ -3241,10 +3406,11 @@ mod tests { use super::{ Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, - ConnectionState, CustomAgentConfig, DeliveryMode, GitHubReferenceType, - InfiniteSessionConfig, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionEvent, - SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, - ToolResultResponse, ensure_attachment_display_names, + ConnectionState, CustomAgentConfig, DeliveryMode, ExtensionRegistrationConfig, + GitHubReferenceType, HostCapabilitiesConfig, InfiniteSessionConfig, PermissionRequestData, + ProviderConfig, ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, + SystemMessageConfig, Tool, ToolBinaryResult, ToolInvocation, ToolResult, + ToolResultExpanded, ToolResultResponse, ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; @@ -3253,6 +3419,8 @@ mod tests { let tool = Tool::new("greet") .with_description("Say hello") .with_namespaced_name("hello/greet") + .with_namespace("tools") + .with_canvas_id("markdown") .with_instructions("Pass the user's name") .with_parameters(json!({ "type": "object", @@ -3264,12 +3432,128 @@ mod tests { assert_eq!(tool.name, "greet"); assert_eq!(tool.description, "Say hello"); assert_eq!(tool.namespaced_name.as_deref(), Some("hello/greet")); + assert_eq!(tool.namespace.as_deref(), Some("tools")); + assert_eq!(tool.canvas_id.as_deref(), Some("markdown")); assert_eq!(tool.instructions.as_deref(), Some("Pass the user's name")); assert_eq!(tool.parameters.get("type").unwrap(), &json!("object")); assert!(tool.overrides_built_in_tool); assert!(tool.skip_permission); } + #[test] + fn session_config_serializes_hosted_extension_handoff_fields() { + let cfg = SessionConfig { + extension_registrations: Some(ExtensionRegistrationConfig { + extension_roots: vec![PathBuf::from("/tmp/ext")], + host_extensions: vec![json!({ + "manifestVersion": 1, + "name": "markdown", + "publisher": "github", + "contributes": { + "canvases": [{ + "id": "markdown", + "implementation": { "kind": "native", "id": "github-app.markdown" } + }] + } + })], + }), + host_capabilities: Some(HostCapabilitiesConfig { canvas: Some(true) }), + request_host_extension: Some(true), + ..Default::default() + }; + + let value = serde_json::to_value(cfg).expect("serialize session config"); + + assert_eq!( + value["extensionRegistrations"]["extensionRoots"], + json!(["/tmp/ext"]) + ); + assert_eq!( + value["extensionRegistrations"]["hostExtensions"][0]["contributes"]["canvases"][0]["implementation"] + ["id"], + "github-app.markdown" + ); + assert!( + value["extensionRegistrations"]["hostExtensions"][0] + .get("manifest") + .is_none(), + "hostExtensions items are bare manifests, not wrapped" + ); + assert_eq!(value["hostCapabilities"]["canvas"], true); + assert_eq!(value["requestHostExtension"], true); + } + + #[test] + fn resume_session_config_serializes_hosted_extension_handoff_fields() { + let mut cfg = ResumeSessionConfig::new(SessionId::from("session-1")); + cfg.extension_registrations = Some(ExtensionRegistrationConfig { + extension_roots: Vec::new(), + host_extensions: vec![json!({ + "manifestVersion": 1, + "name": "markdown", + "publisher": "github" + })], + }); + cfg.host_capabilities = Some(HostCapabilitiesConfig { canvas: Some(true) }); + cfg.request_host_extension = Some(true); + + let value = serde_json::to_value(cfg).expect("serialize resume config"); + + assert_eq!( + value["extensionRegistrations"]["hostExtensions"][0]["name"], + "markdown" + ); + assert_eq!(value["hostCapabilities"]["canvas"], true); + assert_eq!(value["requestHostExtension"], true); + } + + #[test] + fn external_tool_invocation_uses_namespace_field() { + let invocation: ToolInvocation = serde_json::from_value(json!({ + "sessionId": "session-1", + "toolCallId": "call-1", + "toolName": "increment", + "extensionId": "github.markdown", + "namespace": "canvas.tools", + "canvasId": "markdown", + "arguments": { "amount": 1 } + })) + .expect("deserialize invocation"); + + assert_eq!(invocation.extension_id.as_deref(), Some("github.markdown")); + assert_eq!(invocation.namespace.as_deref(), Some("canvas.tools")); + assert_eq!(invocation.canvas_id.as_deref(), Some("markdown")); + let value = serde_json::to_value(invocation).expect("serialize invocation"); + assert_eq!(value["extensionId"], "github.markdown"); + assert_eq!(value["namespace"], "canvas.tools"); + assert_eq!(value["canvasId"], "markdown"); + assert!(value.get("collection").is_none()); + } + + #[test] + fn permission_request_accepts_namespace_and_canvas_id() { + let request: PermissionRequestData = serde_json::from_value(json!({ + "kind": "custom-tool", + "toolCallId": "call-1", + "namespace": "canvas.hostActions", + "canvasId": "markdown", + "promptRequest": { + "kind": "custom-tool", + "toolName": "open_canvas" + } + })) + .expect("deserialize permission request"); + + assert_eq!(request.namespace.as_deref(), Some("canvas.hostActions")); + assert_eq!(request.canvas_id.as_deref(), Some("markdown")); + assert_eq!( + request.extra["promptRequest"]["toolName"].as_str(), + Some("open_canvas") + ); + assert!(request.extra.get("namespace").is_none()); + assert!(request.extra.get("canvasId").is_none()); + } + #[test] fn custom_agent_config_builder_with_model() { let agent = CustomAgentConfig::new("my-agent", "You are helpful.") diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index 2a373a3b5..aa2b751fe 100644 --- a/rust/tests/api_types_test.rs +++ b/rust/tests/api_types_test.rs @@ -95,5 +95,6 @@ fn running_extension(id: &str, name: &str) -> Extension { ExtensionSource::Project }, status: ExtensionStatus::Running, + unavailable_reason: None, } } From f0959934d3d1b24997aa21c1218e2aca3e0bb33f Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 08:44:32 -0700 Subject: [PATCH 02/14] Canvas extensibility V1.1: SDK additive types + dispatch wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive Rust SDK changes for V1.1 canvas extensibility (mirrors the locked TypeScript wire shape on the runtime side). New `canvas` module (rust/src/canvas.rs): - `CanvasDeclaration`, `CanvasAgentActionDeclaration`, `CanvasToolbarItemDeclaration` — wire shape mirroring runtime commit 0d9535192b on jmoseley/canvas-runtime-support (copilot-agent-runtime). - `CanvasHandler` trait + `Canvas` / `CanvasBuilder` ergonomics. - `CanvasOpenContext`, `CanvasActionContext`, `CanvasInitContext`, `CanvasOpenResponse`, `CanvasError` request/response types. - `CanvasRegistry` + `build_registry` + `CanvasInvokeParams` + `dispatch_canvas_invoke` routing implementation. - 11 unit tests covering serialization, registry routing, dispatch semantics. `SessionConfig` / `ResumeSessionConfig` (rust/src/types.rs): - New `canvases: Vec` field — provider declaration. Empty default; skips serialize when empty; never deserializes (handlers are non-Serde types). - New `request_canvas_renderer: Option` field — renderer-side opt-in mirroring `request_elicitation`. Default None; when true, runtime surfaces canvas agent tools (`open_canvas`, `discover_canvases`, ...) to the model. - Matching `with_canvases` / `with_request_canvas_renderer` builders, Debug fields, and Default ctor entries. - `HostCapabilitiesConfig.canvas` doc-comment updated to call out renderer-only semantics (kept during transition; will be deleted once V1.1 finalizes). Dispatch wiring (rust/src/session.rs): - `create_session` / `resume_session` build `Arc` from `config.canvases` and thread it through `spawn_event_loop` to `handle_request`. - `hostExtension.invoke` arm intercepts inner `method == "canvas.action.invoke"`, deserializes `CanvasInvokeParams`, dispatches via `dispatch_canvas_invoke`, wraps result in `HostedExtensionResponse::{Success, Error}`. Falls through to legacy `on_hosted_extension` for non-canvas hostExtension calls. `pub mod canvas` re-exported from `lib.rs`. Validation: cargo test --lib in rust/ passes 147/147 (no regressions; new canvas tests included in count). Cargo check clean. This commit is additive — no breaking changes. Legacy `HostedExtension*` / `host_extensions` / `request_host_extension` / `HostCapabilitiesConfig` paths remain functional during the V1→V1.1 transition. They will be deleted once runtime ships slices E + F and github-app integration testing confirms the new path works end-to-end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/canvas.rs | 717 ++++++++++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 2 + rust/src/session.rs | 76 ++++- rust/src/types.rs | 51 +++- 4 files changed, 842 insertions(+), 4 deletions(-) create mode 100644 rust/src/canvas.rs diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs new file mode 100644 index 000000000..c9cbc9b73 --- /dev/null +++ b/rust/src/canvas.rs @@ -0,0 +1,717 @@ +//! Canvas V1.1 — extension-owned canvases declared via `joinSession({ canvases: [...] })`. +//! +//! This module is the Rust mirror of the locked TypeScript wire shape committed +//! in runtime PR #8441 (`jmoseley/canvas-runtime-support`, commit `0d9535192b`). +//! +//! Status: **additive types + handler trait + Canvas/CanvasBuilder + dispatch routing**. +//! +//! The wire RPC method is still `hostExtension.invoke` (runtime keeps the +//! legacy name); inside, the inner `method == "canvas.action.invoke"` +//! identifies canvas dispatches. Runtime synthesizes +//! `implementationId = "v1.1./"`, but the SDK routes +//! purely on `params.canvasId` + `params.actionName`. +//! +//! Old hosted-extension types in `types.rs` are scheduled for deletion in a +//! follow-up edit once the host fully migrates to the new path. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::types::SessionId; + +/// Declarative metadata for a single canvas, sent over the wire on +/// `session.create` / `session.resume`. +/// +/// Mirrors the TypeScript `CanvasDeclaration` interface verbatim. The +/// `handler` that backs this declaration is held in-process (see [`Canvas`]) +/// and never serialized. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasDeclaration { + /// Canvas identifier, unique within the declaring connection. Stable across + /// resumes — re-declaring with the same `id` replaces the prior entry. + pub id: String, + /// Human-readable name shown in host UI / canvas pickers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Long-form description; surfaced in the agent's discovery prompt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// JSON Schema for the `input` payload accepted by `canvas.open`. + /// Runtime validates incoming `open_canvas` calls against this; handlers + /// never see malformed input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + /// Agent-callable actions this canvas exposes. Names MUST NOT start with + /// `canvas.` (reserved for lifecycle verbs `canvas.{open,focus,close,reload}`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_actions: Option>, + /// User-facing toolbar buttons rendered by the host canvas chrome. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toolbar: Option>, +} + +/// A single agent-callable action contributed by a canvas. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasAgentActionDeclaration { + /// Action identifier, unique within the canvas. MUST NOT start with + /// `canvas.` — that prefix is reserved for lifecycle verbs. + pub name: String, + /// Description shown to the model when picking an action. + pub description: String, + /// Optional JSON Schema for the action's `input` payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_schema: Option, +} + +/// A single toolbar button contributed by a canvas. The host canvas chrome +/// renders these and dispatches `actionName` with optional `input` when +/// clicked. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasToolbarItemDeclaration { + /// Stable id used by the host to key the button. + pub id: String, + /// User-visible label. + pub label: String, + /// Optional icon identifier; semantics are host-defined. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Optional tooltip shown on hover. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tooltip: Option, + /// The `agentActions[].name` to dispatch when clicked. May also be a + /// reserved `canvas.*` verb (e.g. `canvas.reload`) — runtime routes + /// reserved names to the matching lifecycle method. + pub action_name: String, + /// Optional fixed input payload passed verbatim to the action handler. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, +} + +/// Response returned from [`CanvasHandler::on_open`]. The extension's URL is +/// embedded by the host in its webview surface when the host advertises +/// the `canvas.webview` capability. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasOpenResponse { + /// URL the host should embed (typically a loopback HTTP server owned by + /// the extension). Optional for canvases that have no visual surface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// Stable per-instance identifier the extension can correlate with its + /// own state. The host echoes this back on subsequent lifecycle calls. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instance_id: Option, +} + +/// Context handed to [`CanvasHandler::on_open`]. +#[derive(Debug, Clone)] +pub struct CanvasOpenContext { + /// Session that requested the canvas. + pub session_id: SessionId, + /// Canvas id (matches the declaring [`CanvasDeclaration::id`]). + pub canvas_id: String, + /// Validated `input` payload, shaped by [`CanvasDeclaration::input_schema`]. + pub input: Value, + /// Toolbar items declared on the canvas, passed through for handler + /// convenience (e.g. if the extension wants to mirror them in its own UI). + pub toolbar: Option>, +} + +/// Context handed to [`CanvasHandler::on_action`]. +#[derive(Debug, Clone)] +pub struct CanvasActionContext { + /// Session that invoked the action. + pub session_id: SessionId, + /// Canvas id targeted by the action. + pub canvas_id: String, + /// Instance id targeted by the action. + pub instance_id: String, + /// Action name from [`CanvasAgentActionDeclaration::name`]. + pub action_name: String, + /// Validated `input` payload, shaped by the action's `input_schema`. + pub input: Value, +} + +/// Context handed to lifecycle hooks ([`CanvasHandler::on_focus`], +/// [`CanvasHandler::on_close`], [`CanvasHandler::on_reload`]). +#[derive(Debug, Clone)] +pub struct CanvasLifecycleContext { + /// Session owning the canvas instance. + pub session_id: SessionId, + /// Canvas id (matches the declaring [`CanvasDeclaration::id`]). + pub canvas_id: String, + /// Instance id this lifecycle event applies to. + pub instance_id: String, +} + +/// Structured error returned from canvas handlers. Serialized into the +/// `canvas.action.invoke` error envelope. +#[derive(Debug, Clone, Error, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[error("{code}: {message}")] +pub struct CanvasError { + /// Machine-readable error code. Reserved codes: + /// - `canvas_action_no_handler` — action declared but no handler implemented + /// - `canvas_input_invalid` — input failed schema validation (runtime emits) + pub code: String, + /// Human-readable message. + pub message: String, +} + +impl CanvasError { + /// Construct a new error envelope with the given code and message. + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + /// Default error returned by [`CanvasHandler::on_action`] when the + /// handler did not override it — i.e. the canvas declared no + /// `agentActions[]` or forgot to wire one. + pub fn no_handler() -> Self { + Self::new( + "canvas_action_no_handler", + "No handler implemented for this canvas action", + ) + } +} + +/// Result alias for canvas handler methods. +pub type CanvasResult = Result; + +/// Per-canvas handler implementing the lifecycle the runtime dispatches. +/// +/// Each [`Canvas`] owns one `Arc`. The SDK routes incoming +/// `canvas.action.invoke` requests by `(canvas_id, action_name)`: +/// +/// - `canvas.open` → [`Self::on_open`] (required) +/// - `canvas.focus` → [`Self::on_focus`] (default no-op) +/// - `canvas.close` → [`Self::on_close`] (default no-op) +/// - `canvas.reload` → [`Self::on_reload`] (default no-op) +/// - anything else → [`Self::on_action`] (default returns `canvas_action_no_handler`) +/// +/// Implementations may be invoked concurrently — keep them `Send + Sync`. +#[async_trait] +pub trait CanvasHandler: Send + Sync { + /// Required. Open a new canvas instance. Return its URL (if any) and an + /// extension-owned instance id (if any). + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult; + + /// Optional. Handle a non-lifecycle action declared in + /// [`CanvasDeclaration::agent_actions`]. Default returns + /// [`CanvasError::no_handler`] so canvases with no agent actions don't + /// need to think about it. + async fn on_action(&self, _ctx: CanvasActionContext) -> CanvasResult { + Err(CanvasError::no_handler()) + } + + /// Optional. Canvas was brought to the foreground. + async fn on_focus(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { + Ok(()) + } + + /// Optional. Canvas was closed by the user or agent. + async fn on_close(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { + Ok(()) + } + + /// Optional. Host requested a reload (e.g. user hit refresh). + async fn on_reload(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> { + Ok(()) + } +} + +/// A registered canvas: declarative metadata + in-process handler. +/// +/// Construct via [`Canvas::builder`]. The declaration is serialized onto the +/// wire (handlers are dropped — they're not transferable); the handler is +/// retained in the SDK's per-session registry and invoked by +/// `canvas.action.invoke` dispatch keyed by `(canvas_id, action_name)`. +#[derive(Clone)] +pub struct Canvas { + declaration: CanvasDeclaration, + handler: Arc, +} + +impl Canvas { + /// Begin building a canvas from its declarative metadata. Call + /// [`CanvasBuilder::handler`] then [`CanvasBuilder::build`]. + pub fn builder(declaration: CanvasDeclaration) -> CanvasBuilder { + CanvasBuilder { + declaration, + handler: None, + } + } + + /// Borrow the declarative metadata (serialized onto the wire). + pub fn declaration(&self) -> &CanvasDeclaration { + &self.declaration + } + + /// Clone the in-process handler arc for dispatch. + pub fn handler(&self) -> Arc { + self.handler.clone() + } +} + +impl Serialize for Canvas { + fn serialize(&self, serializer: S) -> Result { + self.declaration.serialize(serializer) + } +} + +impl std::fmt::Debug for Canvas { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Canvas") + .field("declaration", &self.declaration) + .field("handler", &"") + .finish() + } +} + +/// Builder for [`Canvas`]. The handler is required; [`Self::build`] panics +/// if called without one (mirrors the Node `createCanvas` requirement that +/// `onOpen` be provided). +pub struct CanvasBuilder { + declaration: CanvasDeclaration, + handler: Option>, +} + +impl CanvasBuilder { + /// Attach the per-canvas handler. Required. + pub fn handler(mut self, handler: Arc) -> Self { + self.handler = Some(handler); + self + } + + /// Finalize into a [`Canvas`]. Panics if [`Self::handler`] was not called. + pub fn build(self) -> Canvas { + let handler = self + .handler + .expect("Canvas::builder().handler(...) must be called before build()"); + Canvas { + declaration: self.declaration, + handler, + } + } +} + +/// Per-session canvas registry, keyed by `canvas_id`. +/// +/// Built from a session's `canvases: Vec` at create/resume time and +/// consulted by the JSON-RPC dispatch path when an incoming +/// `canvas.action.invoke` arrives. +pub type CanvasRegistry = HashMap>; + +/// Build a [`CanvasRegistry`] from a session's declared canvases. +/// +/// Duplicate ids: later entries replace earlier ones (matches the runtime's +/// re-declare-replace semantics on `session.resume`). +pub fn build_registry(canvases: &[Canvas]) -> CanvasRegistry { + let mut map = CanvasRegistry::new(); + for canvas in canvases { + map.insert(canvas.declaration.id.clone(), canvas.handler.clone()); + } + map +} + +/// Wire-level params for `canvas.action.invoke` (the inner `method` field of +/// a `hostExtension.invoke` JSON-RPC request). +/// +/// Mirrors the runtime's `HostedExtensionRequest.params` shape exactly — +/// `canvas-agent-runtime/src/core/server.ts` `dispatchCanvas*`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInvokeParams { + /// Canvas id from the declaring [`CanvasDeclaration::id`]. + pub canvas_id: String, + /// Present for every action except `canvas.open` (runtime allocates + /// the instance id after open returns). + #[serde(default)] + pub instance_id: Option, + /// `canvas.{open,focus,close,reload}` for lifecycle verbs; otherwise a + /// custom action name declared in [`CanvasDeclaration::agent_actions`]. + pub action_name: String, + /// Validated `input` payload. Runtime has already checked it against the + /// canvas's `input_schema` / action's `input_schema`. + #[serde(default)] + pub input: Value, + /// Toolbar items declared on the canvas — runtime passes them through on + /// `canvas.open` so handlers don't need to re-derive their own copy. + #[serde(default)] + pub toolbar: Option>, +} + +/// Resolve a `canvas.action.invoke` request against a registry and run the +/// matching handler method. Returns `Ok(result_value)` on success or +/// `Err(canvas_error)` on failure. +/// +/// Reserved verbs (`canvas.{open,focus,close,reload}`) route to the matching +/// lifecycle method; any other `action_name` routes to +/// [`CanvasHandler::on_action`]. +pub async fn dispatch_canvas_invoke( + registry: &CanvasRegistry, + session_id: SessionId, + params: CanvasInvokeParams, +) -> CanvasResult { + let handler = registry.get(¶ms.canvas_id).cloned().ok_or_else(|| { + CanvasError::new( + "canvas_not_registered", + format!( + "No canvas handler registered for id '{}' in this session", + params.canvas_id + ), + ) + })?; + + match params.action_name.as_str() { + "canvas.open" => { + let ctx = CanvasOpenContext { + session_id, + canvas_id: params.canvas_id, + input: params.input, + toolbar: params.toolbar, + }; + let response = handler.on_open(ctx).await?; + Ok(serde_json::to_value(response).unwrap_or(Value::Null)) + } + verb @ ("canvas.focus" | "canvas.close" | "canvas.reload") => { + let instance_id = params.instance_id.ok_or_else(|| { + CanvasError::new( + "canvas_missing_instance_id", + format!("Lifecycle verb '{verb}' requires an instanceId"), + ) + })?; + let ctx = CanvasLifecycleContext { + session_id, + canvas_id: params.canvas_id, + instance_id, + }; + match verb { + "canvas.focus" => handler.on_focus(ctx).await?, + "canvas.close" => handler.on_close(ctx).await?, + "canvas.reload" => handler.on_reload(ctx).await?, + _ => unreachable!(), + } + Ok(Value::Null) + } + _ => { + let instance_id = params.instance_id.unwrap_or_default(); + let ctx = CanvasActionContext { + session_id, + canvas_id: params.canvas_id, + instance_id, + action_name: params.action_name, + input: params.input, + }; + handler.on_action(ctx).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn declaration_serializes_camel_case_and_skips_none() { + let decl = CanvasDeclaration { + id: "counter".into(), + display_name: Some("Counter".into()), + description: None, + input_schema: None, + agent_actions: Some(vec![CanvasAgentActionDeclaration { + name: "increment".into(), + description: "bump".into(), + input_schema: None, + }]), + toolbar: None, + }; + let v = serde_json::to_value(&decl).unwrap(); + assert_eq!(v["id"], "counter"); + assert_eq!(v["displayName"], "Counter"); + assert!(v.get("description").is_none()); + assert!(v.get("inputSchema").is_none()); + assert_eq!(v["agentActions"][0]["name"], "increment"); + assert!(v.get("toolbar").is_none()); + } + + #[test] + fn toolbar_item_round_trip() { + let item = CanvasToolbarItemDeclaration { + id: "reload".into(), + label: "Reload".into(), + icon: Some("refresh".into()), + tooltip: None, + action_name: "canvas.reload".into(), + input: Some(json!({ "force": true })), + }; + let v = serde_json::to_value(&item).unwrap(); + assert_eq!(v["actionName"], "canvas.reload"); + assert_eq!(v["input"]["force"], true); + let back: CanvasToolbarItemDeclaration = serde_json::from_value(v).unwrap(); + assert_eq!(back, item); + } + + struct EchoHandler; + + #[async_trait] + impl CanvasHandler for EchoHandler { + async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse { + url: Some(format!("https://example.test/{}", ctx.canvas_id)), + instance_id: Some(format!("instance-of-{}", ctx.canvas_id)), + }) + } + + async fn on_action(&self, ctx: CanvasActionContext) -> CanvasResult { + Ok(json!({ "echoed": ctx.action_name, "input": ctx.input })) + } + } + + #[tokio::test] + async fn canvas_serializes_as_declaration() { + let canvas = Canvas::builder(CanvasDeclaration { + id: "echo".into(), + display_name: Some("Echo".into()), + ..Default::default() + }) + .handler(Arc::new(EchoHandler)) + .build(); + let v = serde_json::to_value(&canvas).unwrap(); + assert_eq!(v["id"], "echo"); + assert_eq!(v["displayName"], "Echo"); + assert!(v.get("handler").is_none()); + } + + #[tokio::test] + async fn default_on_action_returns_no_handler() { + // EchoHandler overrides on_action; use a bare handler here to hit the default. + struct OpenOnly; + #[async_trait] + impl CanvasHandler for OpenOnly { + async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse::default()) + } + } + let canvas = Canvas::builder(CanvasDeclaration { + id: "bare".into(), + ..Default::default() + }) + .handler(Arc::new(OpenOnly)) + .build(); + + let err = canvas + .handler() + .on_action(CanvasActionContext { + session_id: SessionId::from("s1"), + canvas_id: "bare".into(), + instance_id: "i1".into(), + action_name: "noop".into(), + input: Value::Null, + }) + .await + .unwrap_err(); + assert_eq!(err.code, "canvas_action_no_handler"); + } + + #[tokio::test] + async fn default_lifecycle_hooks_are_no_op() { + struct OpenOnly; + #[async_trait] + impl CanvasHandler for OpenOnly { + async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse::default()) + } + } + let canvas = Canvas::builder(CanvasDeclaration { + id: "bare".into(), + ..Default::default() + }) + .handler(Arc::new(OpenOnly)) + .build(); + + let ctx = CanvasLifecycleContext { + session_id: SessionId::from("s1"), + canvas_id: "bare".into(), + instance_id: "i1".into(), + }; + canvas.handler().on_focus(ctx.clone()).await.unwrap(); + canvas.handler().on_close(ctx.clone()).await.unwrap(); + canvas.handler().on_reload(ctx).await.unwrap(); + } + + #[tokio::test] + async fn dispatch_routes_canvas_open() { + let canvas = Canvas::builder(CanvasDeclaration { + id: "echo".into(), + ..Default::default() + }) + .handler(Arc::new(EchoHandler)) + .build(); + let registry = build_registry(&[canvas]); + + let params = CanvasInvokeParams { + canvas_id: "echo".into(), + instance_id: None, + action_name: "canvas.open".into(), + input: json!({ "x": 1 }), + toolbar: None, + }; + let result = dispatch_canvas_invoke(®istry, SessionId::from("s1"), params) + .await + .unwrap(); + assert_eq!(result["url"], "https://example.test/echo"); + assert_eq!(result["instanceId"], "instance-of-echo"); + } + + #[tokio::test] + async fn dispatch_routes_lifecycle_verbs() { + let canvas = Canvas::builder(CanvasDeclaration { + id: "echo".into(), + ..Default::default() + }) + .handler(Arc::new(EchoHandler)) + .build(); + let registry = build_registry(&[canvas]); + + for verb in ["canvas.focus", "canvas.close", "canvas.reload"] { + let params = CanvasInvokeParams { + canvas_id: "echo".into(), + instance_id: Some("inst-1".into()), + action_name: verb.into(), + input: Value::Null, + toolbar: None, + }; + let result = dispatch_canvas_invoke(®istry, SessionId::from("s1"), params) + .await + .unwrap(); + assert!(result.is_null(), "verb {verb} should return null"); + } + } + + #[tokio::test] + async fn dispatch_routes_custom_action() { + let canvas = Canvas::builder(CanvasDeclaration { + id: "echo".into(), + ..Default::default() + }) + .handler(Arc::new(EchoHandler)) + .build(); + let registry = build_registry(&[canvas]); + + let params = CanvasInvokeParams { + canvas_id: "echo".into(), + instance_id: Some("inst-1".into()), + action_name: "shout".into(), + input: json!("hi"), + toolbar: None, + }; + let result = dispatch_canvas_invoke(®istry, SessionId::from("s1"), params) + .await + .unwrap(); + assert_eq!(result["echoed"], "shout"); + assert_eq!(result["input"], "hi"); + } + + #[tokio::test] + async fn dispatch_unknown_canvas_errors() { + let registry = CanvasRegistry::new(); + let params = CanvasInvokeParams { + canvas_id: "missing".into(), + instance_id: None, + action_name: "canvas.open".into(), + input: Value::Null, + toolbar: None, + }; + let err = dispatch_canvas_invoke(®istry, SessionId::from("s1"), params) + .await + .unwrap_err(); + assert_eq!(err.code, "canvas_not_registered"); + } + + #[tokio::test] + async fn dispatch_lifecycle_without_instance_id_errors() { + let canvas = Canvas::builder(CanvasDeclaration { + id: "echo".into(), + ..Default::default() + }) + .handler(Arc::new(EchoHandler)) + .build(); + let registry = build_registry(&[canvas]); + + let params = CanvasInvokeParams { + canvas_id: "echo".into(), + instance_id: None, + action_name: "canvas.close".into(), + input: Value::Null, + toolbar: None, + }; + let err = dispatch_canvas_invoke(®istry, SessionId::from("s1"), params) + .await + .unwrap_err(); + assert_eq!(err.code, "canvas_missing_instance_id"); + } + + #[tokio::test] + async fn build_registry_replaces_duplicate_ids() { + struct FirstHandler; + #[async_trait] + impl CanvasHandler for FirstHandler { + async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse { + url: Some("first".into()), + instance_id: None, + }) + } + } + struct SecondHandler; + #[async_trait] + impl CanvasHandler for SecondHandler { + async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult { + Ok(CanvasOpenResponse { + url: Some("second".into()), + instance_id: None, + }) + } + } + let first = Canvas::builder(CanvasDeclaration { + id: "dup".into(), + ..Default::default() + }) + .handler(Arc::new(FirstHandler)) + .build(); + let second = Canvas::builder(CanvasDeclaration { + id: "dup".into(), + ..Default::default() + }) + .handler(Arc::new(SecondHandler)) + .build(); + let registry = build_registry(&[first, second]); + let result = dispatch_canvas_invoke( + ®istry, + SessionId::from("s1"), + CanvasInvokeParams { + canvas_id: "dup".into(), + instance_id: None, + action_name: "canvas.open".into(), + input: Value::Null, + toolbar: None, + }, + ) + .await + .unwrap(); + assert_eq!(result["url"], "second"); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 6d219a48d..96d66d556 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -3,6 +3,8 @@ #![deny(rustdoc::broken_intra_doc_links)] #![cfg_attr(test, allow(clippy::unwrap_used))] +/// Canvas V1.1 — extension-owned canvas declarations and per-canvas handlers. +pub mod canvas; /// Bundled CLI binary extraction and caching. pub mod embeddedcli; /// Event handler traits for session lifecycle. diff --git a/rust/src/session.rs b/rust/src/session.rs index 09d62e616..6ab236bbc 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -793,7 +793,13 @@ impl Client { .clone() .unwrap_or_else(|| SessionId::from(uuid::Uuid::new_v4().to_string())); config.session_id = Some(session_id.clone()); + let canvases = std::mem::take(&mut config.canvases); + let canvas_registry = Arc::new(crate::canvas::build_registry(&canvases)); let mut params = serde_json::to_value(&config)?; + // `canvases` is serialized via `Vec` custom impl on `config` + // (handler arcs dropped); the registry is retained locally to dispatch + // `canvas.action.invoke` callbacks. + let _ = canvases; let trace_ctx = self.resolve_trace_context().await; inject_trace_context(&mut params, &trace_ctx); @@ -811,6 +817,7 @@ impl Client { transforms, command_handlers, session_fs_provider, + canvas_registry, channels, idle_waiter.clone(), capabilities.clone(), @@ -920,7 +927,10 @@ impl Client { inject_transform_sections_resume(&mut config, transforms.as_ref()); } let session_id = config.session_id.clone(); + let canvases = std::mem::take(&mut config.canvases); + let canvas_registry = Arc::new(crate::canvas::build_registry(&canvases)); let mut params = serde_json::to_value(&config)?; + let _ = canvases; let trace_ctx = self.resolve_trace_context().await; inject_trace_context(&mut params, &trace_ctx); @@ -938,6 +948,7 @@ impl Client { transforms, command_handlers, session_fs_provider, + canvas_registry, channels, idle_waiter.clone(), capabilities.clone(), @@ -1065,6 +1076,7 @@ fn spawn_event_loop( transforms: Option>, command_handlers: Arc, session_fs_provider: Option>, + canvas_registry: Arc, channels: crate::router::SessionChannels, idle_waiter: Arc>>, capabilities: Arc>, @@ -1099,7 +1111,7 @@ fn spawn_event_loop( } Some(request) = requests.recv() => { handle_request( - &session_id, &client, &handler, hooks.as_deref(), transforms.as_deref(), session_fs_provider.as_ref(), request, + &session_id, &client, &handler, hooks.as_deref(), transforms.as_deref(), session_fs_provider.as_ref(), &canvas_registry, request, ).await; } else => break, @@ -1710,6 +1722,7 @@ async fn handle_notification( } /// Process a JSON-RPC request from the CLI. +#[allow(clippy::too_many_arguments)] async fn handle_request( session_id: &SessionId, client: &Client, @@ -1717,6 +1730,7 @@ async fn handle_request( hooks: Option<&dyn SessionHooks>, transforms: Option<&dyn SystemMessageTransform>, session_fs_provider: Option<&Arc>, + canvas_registry: &Arc, request: crate::JsonRpcRequest, ) { let sid = session_id.clone(); @@ -1820,6 +1834,66 @@ async fn handle_request( } }; + // V1.1 canvas dispatch: any inner `method == "canvas.action.invoke"` + // routes through the canvas registry built from `SessionConfig.canvases`. + // Falls back to the legacy `on_hosted_extension` path otherwise (used by + // V1 hosted-extension consumers until that surface is deleted). + if host_request.request.method == "canvas.action.invoke" { + let result = match serde_json::from_value::( + host_request.request.params.clone(), + ) { + Ok(params) => { + let dispatch_start = Instant::now(); + let canvas_id = params.canvas_id.clone(); + let action_name = params.action_name.clone(); + let response = crate::canvas::dispatch_canvas_invoke( + canvas_registry, + host_request.session_id, + params, + ) + .await; + tracing::debug!( + elapsed_ms = dispatch_start.elapsed().as_millis(), + session_id = %sid, + canvas_id = %canvas_id, + action_name = %action_name, + ok = response.is_ok(), + "canvas.action.invoke dispatch" + ); + match response { + Ok(value) => crate::types::HostedExtensionResponse::Success( + crate::types::HostedExtensionSuccessResponse { + ok: true, + result: value, + }, + ), + Err(err) => crate::types::HostedExtensionResponse::Error( + crate::types::HostedExtensionErrorResponse { + ok: false, + error: crate::types::HostedExtensionError { + code: err.code, + message: err.message, + required_capabilities: Vec::new(), + }, + }, + ), + } + } + Err(err) => crate::types::HostedExtensionResponse::error( + "canvas_invalid_params", + format!("invalid canvas.action.invoke params: {err}"), + ), + }; + let rpc_response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id, + result: Some(serde_json::json!(result)), + error: None, + }; + let _ = client.send_response(&rpc_response).await; + return; + } + let handler_start = Instant::now(); let response = handler .on_event(HandlerEvent::HostedExtension { diff --git a/rust/src/types.rs b/rust/src/types.rs index 1d8c8956e..65e613ada 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1011,9 +1011,12 @@ pub struct ExtensionRegistrationConfig { #[serde(rename_all = "camelCase")] #[allow(missing_docs)] pub struct HostCapabilitiesConfig { - /// When `true`, the runtime exposes the canvas agent tools - /// (`open_canvas`, `focus_canvas`, `close_canvas`, `reload_canvas`, - /// `discover_canvases`) and adds the `canvas-host` session capability. + /// Renderer-side opt-in (V1.1): when `true`, the runtime surfaces canvas + /// agent tools (`open_canvas`, `discover_canvases`, `focus_canvas`, + /// `close_canvas`, `reload_canvas`) to the model for this connection. + /// Default off — TUI / headless / SDK callers stay clean unless they can + /// actually display canvases. This is independent of provider semantics, + /// which are declared via `SessionConfig.canvases`. #[serde(default, skip_serializing_if = "Option::is_none")] pub canvas: Option, } @@ -1201,6 +1204,13 @@ pub struct SessionConfig { /// Defaults to `Some(true)` via [`SessionConfig::default`]. #[serde(skip_serializing_if = "Option::is_none")] pub request_elicitation: Option, + /// Renderer-side opt-in (V1.1): when `true`, the runtime surfaces canvas + /// agent tools (`open_canvas`, `discover_canvases`, ...) to the model. + /// Default off — TUI / headless / SDK callers stay clean unless they can + /// actually display canvases. Independent of provider semantics, which + /// are declared via [`canvases`](Self::canvases). + #[serde(skip_serializing_if = "Option::is_none")] + pub request_canvas_renderer: Option, /// Skill directory paths passed through to the GitHub Copilot CLI. #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -1287,6 +1297,12 @@ pub struct SessionConfig { /// associated [`CommandHandler`] is called when executed. #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] pub commands: Option>, + /// Canvas V1.1 declarations. Each entry binds a [`CanvasDeclaration`] + + /// [`crate::canvas::CanvasHandler`] for this session; the runtime treats + /// the declaring connection as the live provider for every declared + /// canvas id. Serialized as an array of `CanvasDeclaration` on the wire. + #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] + pub canvases: Vec, /// Host-provided extension registrations for the temporary canvas POC. #[serde(skip_serializing_if = "Option::is_none")] pub extension_registrations: Option, @@ -1340,6 +1356,7 @@ impl std::fmt::Debug for SessionConfig { .field("request_exit_plan_mode", &self.request_exit_plan_mode) .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) + .field("request_canvas_renderer", &self.request_canvas_renderer) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("disabled_skills", &self.disabled_skills) @@ -1364,6 +1381,7 @@ impl std::fmt::Debug for SessionConfig { &self.include_sub_agent_streaming_events, ) .field("commands", &self.commands) + .field("canvases", &self.canvases) .field("extension_registrations", &self.extension_registrations) .field("request_host_extension", &self.request_host_extension) .field( @@ -1405,6 +1423,7 @@ impl Default for SessionConfig { request_exit_plan_mode: Some(true), request_auto_mode_switch: Some(true), request_elicitation: Some(true), + request_canvas_renderer: None, skill_directories: None, instruction_directories: None, disabled_skills: None, @@ -1423,6 +1442,7 @@ impl Default for SessionConfig { cloud: None, include_sub_agent_streaming_events: None, commands: None, + canvases: Vec::new(), extension_registrations: None, host_capabilities: None, request_host_extension: None, @@ -1628,6 +1648,12 @@ impl SessionConfig { self } + /// Renderer-side opt-in (V1.1): surface canvas agent tools to the model. + pub fn with_request_canvas_renderer(mut self, enable: bool) -> Self { + self.request_canvas_renderer = Some(enable); + self + } + /// Set skill directory paths passed through to the CLI. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -1817,6 +1843,10 @@ pub struct ResumeSessionConfig { /// Advertise elicitation provider capability on resume. #[serde(skip_serializing_if = "Option::is_none")] pub request_elicitation: Option, + /// Renderer-side opt-in (V1.1) on resume; see + /// [`SessionConfig::request_canvas_renderer`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_canvas_renderer: Option, /// Skill directory paths passed through to the GitHub Copilot CLI on resume. #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -1879,6 +1909,11 @@ pub struct ResumeSessionConfig { /// so the resume payload re-supplies the registration. #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] pub commands: Option>, + /// Canvas V1.1 declarations to (re-)register on resume. Same semantics + /// as [`SessionConfig::canvases`]; re-declaring a canvas id replaces + /// the prior entry on the runtime side. + #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] + pub canvases: Vec, /// Host-provided extension registrations for the temporary canvas POC. #[serde(skip_serializing_if = "Option::is_none")] pub extension_registrations: Option, @@ -1936,6 +1971,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("request_exit_plan_mode", &self.request_exit_plan_mode) .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) + .field("request_canvas_renderer", &self.request_canvas_renderer) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("disabled_skills", &self.disabled_skills) @@ -1959,6 +1995,7 @@ impl std::fmt::Debug for ResumeSessionConfig { &self.include_sub_agent_streaming_events, ) .field("commands", &self.commands) + .field("canvases", &self.canvases) .field("extension_registrations", &self.extension_registrations) .field("request_host_extension", &self.request_host_extension) .field( @@ -2000,6 +2037,7 @@ impl ResumeSessionConfig { request_exit_plan_mode: Some(true), request_auto_mode_switch: Some(true), request_elicitation: Some(true), + request_canvas_renderer: None, skill_directories: None, instruction_directories: None, disabled_skills: None, @@ -2017,6 +2055,7 @@ impl ResumeSessionConfig { remote_session: None, include_sub_agent_streaming_events: None, commands: None, + canvases: Vec::new(), extension_registrations: None, host_capabilities: None, request_host_extension: None, @@ -2194,6 +2233,12 @@ impl ResumeSessionConfig { self } + /// Renderer-side opt-in (V1.1) on resume: surface canvas agent tools to the model. + pub fn with_request_canvas_renderer(mut self, enable: bool) -> Self { + self.request_canvas_renderer = Some(enable); + self + } + /// Set skill directory paths passed through to the CLI on resume. pub fn with_skill_directories(mut self, paths: I) -> Self where From 56c1efe2c0557d40de5da3baddf8e656dc1ad7d1 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 09:01:04 -0700 Subject: [PATCH 03/14] Canvas V1.1: drop HostCapabilitiesConfig (renamed to requestCanvasRenderer upstream) Runtime PR #8441 commit 11e040dc1b renamed the renderer-capability gate from hostCapabilities.canvas to a top-level requestCanvasRenderer field on SessionCreate/Resume, mirroring requestElicitation. The Rust SDK already has request_canvas_renderer wired, so the legacy field + struct are now dead. - Delete HostCapabilitiesConfig struct. - Drop host_capabilities field from SessionConfig + ResumeSessionConfig (including Default impls). - Update serialization tests to drop hostCapabilities assertions. 147/147 lib tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/types.rs | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/rust/src/types.rs b/rust/src/types.rs index 65e613ada..38c6b603c 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1005,22 +1005,6 @@ pub struct ExtensionRegistrationConfig { pub host_extensions: Vec, } -/// Host-advertised runtime capabilities. The runtime gates corresponding -/// agent tools based on what the host implements. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[allow(missing_docs)] -pub struct HostCapabilitiesConfig { - /// Renderer-side opt-in (V1.1): when `true`, the runtime surfaces canvas - /// agent tools (`open_canvas`, `discover_canvases`, `focus_canvas`, - /// `close_canvas`, `reload_canvas`) to the model for this connection. - /// Default off — TUI / headless / SDK callers stay clean unless they can - /// actually display canvases. This is independent of provider semantics, - /// which are declared via `SessionConfig.canvases`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub canvas: Option, -} - /// Server-to-client hosted extension callback request. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -1306,9 +1290,6 @@ pub struct SessionConfig { /// Host-provided extension registrations for the temporary canvas POC. #[serde(skip_serializing_if = "Option::is_none")] pub extension_registrations: Option, - /// Host-advertised runtime capabilities (gates canvas agent tools, etc.). - #[serde(skip_serializing_if = "Option::is_none")] - pub host_capabilities: Option, /// Ask the runtime to route hosted extension callbacks over this SDK connection. #[serde(skip_serializing_if = "Option::is_none")] pub request_host_extension: Option, @@ -1444,7 +1425,6 @@ impl Default for SessionConfig { commands: None, canvases: Vec::new(), extension_registrations: None, - host_capabilities: None, request_host_extension: None, session_fs_provider: None, handler: None, @@ -1917,9 +1897,6 @@ pub struct ResumeSessionConfig { /// Host-provided extension registrations for the temporary canvas POC. #[serde(skip_serializing_if = "Option::is_none")] pub extension_registrations: Option, - /// Host-advertised runtime capabilities (gates canvas agent tools, etc.). - #[serde(skip_serializing_if = "Option::is_none")] - pub host_capabilities: Option, /// Ask the runtime to route hosted extension callbacks over this SDK connection. #[serde(skip_serializing_if = "Option::is_none")] pub request_host_extension: Option, @@ -2057,7 +2034,6 @@ impl ResumeSessionConfig { commands: None, canvases: Vec::new(), extension_registrations: None, - host_capabilities: None, request_host_extension: None, session_fs_provider: None, disable_resume: None, @@ -3452,7 +3428,7 @@ mod tests { use super::{ Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, ConnectionState, CustomAgentConfig, DeliveryMode, ExtensionRegistrationConfig, - GitHubReferenceType, HostCapabilitiesConfig, InfiniteSessionConfig, PermissionRequestData, + GitHubReferenceType, InfiniteSessionConfig, PermissionRequestData, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolInvocation, ToolResult, ToolResultExpanded, ToolResultResponse, ensure_attachment_display_names, @@ -3502,7 +3478,6 @@ mod tests { } })], }), - host_capabilities: Some(HostCapabilitiesConfig { canvas: Some(true) }), request_host_extension: Some(true), ..Default::default() }; @@ -3524,7 +3499,6 @@ mod tests { .is_none(), "hostExtensions items are bare manifests, not wrapped" ); - assert_eq!(value["hostCapabilities"]["canvas"], true); assert_eq!(value["requestHostExtension"], true); } @@ -3539,7 +3513,6 @@ mod tests { "publisher": "github" })], }); - cfg.host_capabilities = Some(HostCapabilitiesConfig { canvas: Some(true) }); cfg.request_host_extension = Some(true); let value = serde_json::to_value(cfg).expect("serialize resume config"); @@ -3548,7 +3521,6 @@ mod tests { value["extensionRegistrations"]["hostExtensions"][0]["name"], "markdown" ); - assert_eq!(value["hostCapabilities"]["canvas"], true); assert_eq!(value["requestHostExtension"], true); } From 322306b1e403a4681f58ca8d8ac073d640842f65 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 09:14:23 -0700 Subject: [PATCH 04/14] Canvas V1.1: Node SDK createCanvas factory + canvases declaration wire-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice F of the V1.1 canvas extensibility cutover. Adds the Node-side counterpart to the Rust SDK Canvas/CanvasBuilder shape (commit f0959934) so extensions can declare canvases dynamically via joinSession instead of a static copilot-extension.json manifest. Surface (exported from both '@github/copilot-sdk' and '@github/copilot-sdk/extension'): - createCanvas(options): Canvas — packages a CanvasDeclaration with in-process onOpen / onAction / onFocus / onClose / onReload closures. - CanvasDeclaration, CanvasAgentActionDeclaration, CanvasToolbarItemDeclaration — wire shape (mirrors copilot-agent-runtime src/core/protocol/types.ts on jmoseley/canvas-runtime-support@0d9535192b). - CanvasOpenContext, CanvasActionContext, CanvasLifecycleContext, CanvasOpenResponse, CanvasOptions, CanvasError. Wire-up: - SessionConfig + ResumeSessionConfig gain canvases?: Canvas[] and requestCanvasRenderer?: boolean. - client.ts createSession / resumeSession serialize canvases.map(c => c.declaration) onto the session.create / session.resume RPC and pass through requestCanvasRenderer. - CopilotSession gains a per-session canvas registry (registerCanvases / getCanvas) parallel to the existing toolHandlers registry. - client.ts registers a 'hostExtension.invoke' onRequest handler that intercepts inner method === 'canvas.action.invoke', routes by (canvasId, actionName) to the registered Canvas's handlers, and surfaces CanvasError as the wire error envelope. Other inner methods are rejected — no other hostExtension.invoke variants are in use post-V1.1. Example: import { joinSession, createCanvas } from '@github/copilot-sdk/extension'; const counter = createCanvas({ id: 'counter', onOpen: async (ctx) => ({ url: 'http://localhost:3000' }), onAction: async (ctx) => ({ value: 1 }), }); await joinSession({ canvases: [counter] }); typecheck clean; 146/147 unit tests pass (1 pre-existing skip). Build emits dist/canvas.{js,d.ts} + dist/cjs equivalents and re-exports createCanvas from dist/extension.{js,d.ts} and dist/index.{js,d.ts}. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 351 ++++++++++++++++++++++++++++++++++++++++ nodejs/src/client.ts | 65 ++++++++ nodejs/src/extension.ts | 14 ++ nodejs/src/index.ts | 13 ++ nodejs/src/session.ts | 27 ++++ nodejs/src/types.ts | 22 +++ 6 files changed, 492 insertions(+) create mode 100644 nodejs/src/canvas.ts diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts new file mode 100644 index 000000000..d6c53c788 --- /dev/null +++ b/nodejs/src/canvas.ts @@ -0,0 +1,351 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Canvas V1.1 — extension-owned canvases declared via + * `joinSession({ canvases: [createCanvas({...})] })`. + * + * The on-the-wire declaration shape mirrors the runtime's `CanvasDeclaration` + * interface verbatim. The `createCanvas` helper packages the declaration with + * in-process handler closures; the SDK serializes the declaration onto + * `session.create` / `session.resume` and routes incoming + * `canvas.action.invoke` dispatches by `(canvasId, actionName)` back to the + * handlers. + * + * The wire RPC method is still `hostExtension.invoke` (runtime preserves the + * legacy name); inside, `method === "canvas.action.invoke"` identifies canvas + * dispatches. The runtime synthesizes an internal + * `implementationId = "v1.1./"`, but the SDK ignores + * it and routes purely on `params.canvasId` + `params.actionName`. + */ + +/** + * A single agent-callable action contributed by a canvas. Names MUST NOT + * start with `canvas.` — that prefix is reserved for the lifecycle verbs + * `canvas.{open,focus,close,reload}`. + */ +export interface CanvasAgentActionDeclaration { + /** Action identifier, unique within the canvas. */ + name: string; + /** Description shown to the model when picking an action. */ + description: string; + /** Optional JSON Schema for the action's `input` payload. */ + inputSchema?: Record; +} + +/** + * A single toolbar button contributed by a canvas. The host canvas chrome + * renders these and dispatches `actionName` with optional `input` when + * clicked. `actionName` may be a reserved `canvas.*` verb (e.g. + * `canvas.reload`) — the runtime routes those to the matching lifecycle + * method. + */ +export interface CanvasToolbarItemDeclaration { + /** Stable id used by the host to key the button. */ + id: string; + /** User-visible label. */ + label: string; + /** Optional icon identifier; semantics are host-defined. */ + icon?: string; + /** Optional tooltip shown on hover. */ + tooltip?: string; + /** The `agentActions[].name` (or reserved `canvas.*` verb) to dispatch. */ + actionName: string; + /** Optional fixed input payload passed verbatim to the action handler. */ + input?: unknown; +} + +/** + * Declarative metadata for a single canvas, serialized over the wire on + * `session.create` / `session.resume`. The declaring connection becomes the + * live provider for dispatched canvas operations targeting this `id` for the + * lifetime of the connection; re-declaring the same `id` on resume replaces + * the prior declaration. + */ +export interface CanvasDeclaration { + /** Canvas id, unique within the declaring connection. */ + id: string; + /** Human-readable label shown in `discover_canvases` and host UI chrome. */ + displayName?: string; + /** One-line description shown in `discover_canvases` for agent reasoning. */ + description?: string; + /** + * Optional JSON Schema for the `input` payload accepted by `canvas.open`. + * The runtime validates incoming `open_canvas` calls against this; + * handlers never see malformed input. + */ + inputSchema?: Record; + /** Static toolbar items rendered as host chrome. */ + toolbar?: CanvasToolbarItemDeclaration[]; + /** Agent-invocable actions exposed via `invoke_canvas_action`. */ + agentActions?: CanvasAgentActionDeclaration[]; +} + +/** + * Response returned from `onOpen`. The extension's URL is embedded by the + * host in its webview surface when the host advertises the `canvas.webview` + * capability. + */ +export interface CanvasOpenResponse { + /** URL the host should embed. Optional for canvases with no visual surface. */ + url?: string; + /** + * Stable per-instance identifier the extension can correlate with its own + * state. The host echoes this back on subsequent lifecycle calls. + */ + instanceId?: string; +} + +/** Context handed to a canvas's `onOpen` handler. */ +export interface CanvasOpenContext { + /** Session that requested the canvas. */ + sessionId: string; + /** Canvas id (matches the declaring `CanvasDeclaration.id`). */ + canvasId: string; + /** Validated `input` payload, shaped by `CanvasDeclaration.inputSchema`. */ + input: unknown; + /** Toolbar items declared on the canvas, passed through for convenience. */ + toolbar?: CanvasToolbarItemDeclaration[]; +} + +/** Context handed to a canvas's `onAction` handler. */ +export interface CanvasActionContext { + /** Session that invoked the action. */ + sessionId: string; + /** Canvas id targeted by the action. */ + canvasId: string; + /** Instance id targeted by the action. */ + instanceId: string; + /** Action name from `CanvasAgentActionDeclaration.name`. */ + actionName: string; + /** Validated `input` payload, shaped by the action's `inputSchema`. */ + input: unknown; +} + +/** Context handed to a canvas's lifecycle hooks (`onFocus`, `onClose`, `onReload`). */ +export interface CanvasLifecycleContext { + /** Session owning the canvas instance. */ + sessionId: string; + /** Canvas id (matches the declaring `CanvasDeclaration.id`). */ + canvasId: string; + /** Instance id this lifecycle event applies to. */ + instanceId: string; +} + +/** + * Structured error returned from canvas handlers. Serialized into the + * `canvas.action.invoke` error envelope. + * + * Reserved codes: + * - `canvas_action_no_handler` — action declared but no `onAction` provided + * - `canvas_input_invalid` — input failed schema validation (runtime emits) + */ +export class CanvasError extends Error { + constructor( + public readonly code: string, + message: string + ) { + super(message); + this.name = "CanvasError"; + } + + /** Default error when an action is declared but no `onAction` is wired. */ + static noHandler(): CanvasError { + return new CanvasError( + "canvas_action_no_handler", + "No handler implemented for this canvas action" + ); + } +} + +/** + * Options accepted by {@link createCanvas}. Combines the declarative + * {@link CanvasDeclaration} fields with the in-process handler closures + * the SDK invokes on `canvas.action.invoke` dispatch. + */ +export interface CanvasOptions { + /** @see CanvasDeclaration.id */ + id: string; + /** @see CanvasDeclaration.displayName */ + displayName?: string; + /** @see CanvasDeclaration.description */ + description?: string; + /** @see CanvasDeclaration.inputSchema */ + inputSchema?: Record; + /** @see CanvasDeclaration.agentActions */ + agentActions?: CanvasAgentActionDeclaration[]; + /** @see CanvasDeclaration.toolbar */ + toolbar?: CanvasToolbarItemDeclaration[]; + + /** + * Required. Open a new canvas instance. Return its URL (if any) and an + * extension-owned instance id (if any). + */ + onOpen: (ctx: CanvasOpenContext) => Promise | CanvasOpenResponse; + + /** + * Optional. Handle a non-lifecycle action declared in `agentActions`. + * If omitted, dispatched actions return `canvas_action_no_handler`. + */ + onAction?: (ctx: CanvasActionContext) => Promise | unknown; + + /** Optional. Canvas was brought to the foreground. */ + onFocus?: (ctx: CanvasLifecycleContext) => Promise | void; + + /** Optional. Canvas was closed by the user or agent. */ + onClose?: (ctx: CanvasLifecycleContext) => Promise | void; + + /** Optional. Host requested a reload (e.g. user hit refresh). */ + onReload?: (ctx: CanvasLifecycleContext) => Promise | void; +} + +/** + * A registered canvas: declarative metadata + in-process handler closures. + * + * Construct via {@link createCanvas}. The {@link declaration} is serialized + * onto the wire (handlers are dropped — they're not transferable); the + * handlers are retained in the SDK's per-session registry and invoked by + * `canvas.action.invoke` dispatch keyed by `(canvasId, actionName)`. + */ +export class Canvas { + readonly declaration: CanvasDeclaration; + readonly onOpen: NonNullable; + readonly onAction?: CanvasOptions["onAction"]; + readonly onFocus?: CanvasOptions["onFocus"]; + readonly onClose?: CanvasOptions["onClose"]; + readonly onReload?: CanvasOptions["onReload"]; + + /** @internal */ + constructor(options: CanvasOptions) { + this.declaration = { + id: options.id, + displayName: options.displayName, + description: options.description, + inputSchema: options.inputSchema, + toolbar: options.toolbar, + agentActions: options.agentActions, + }; + this.onOpen = options.onOpen; + this.onAction = options.onAction; + this.onFocus = options.onFocus; + this.onClose = options.onClose; + this.onReload = options.onReload; + } +} + +/** + * Create a canvas declaration with bound in-process handlers. Pass the result + * to `joinSession({ canvases: [...] })` (or the client `createSession` / + * `resumeSession` `canvases` field). The SDK serializes + * {@link Canvas.declaration} onto `session.create` / `session.resume` and + * routes incoming `canvas.action.invoke` dispatches back to the handlers. + * + * @example + * ```typescript + * import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; + * + * const counter = createCanvas({ + * id: "counter", + * displayName: "Counter", + * description: "A trivial counter canvas", + * agentActions: [{ name: "increment", description: "Add one" }], + * onOpen: async (ctx) => ({ url: `http://localhost:3000/${ctx.canvasId}` }), + * onAction: async (ctx) => { + * if (ctx.actionName === "increment") return { value: 1 }; + * }, + * }); + * + * await joinSession({ canvases: [counter] }); + * ``` + */ +export function createCanvas(options: CanvasOptions): Canvas { + return new Canvas(options); +} + +// --------------------------------------------------------------------------- +// Internal dispatch helpers (consumed by client.ts / session.ts). +// --------------------------------------------------------------------------- + +/** + * Inner envelope of a `hostExtension.invoke` request when the dispatched + * method is `canvas.action.invoke`. Field names mirror the runtime contract. + * + * @internal + */ +export interface CanvasActionInvokeParams { + canvasId: string; + instanceId?: string; + actionName: string; + input?: unknown; + toolbar?: CanvasToolbarItemDeclaration[]; +} + +/** + * Reserved lifecycle action names. Any other `actionName` routes to + * {@link Canvas.onAction}. + * + * @internal + */ +export const RESERVED_CANVAS_ACTIONS = { + open: "canvas.open", + focus: "canvas.focus", + close: "canvas.close", + reload: "canvas.reload", +} as const; + +/** + * Dispatch a `canvas.action.invoke` payload to the matching {@link Canvas}'s + * handler. Returns the value the handler produced (for `onOpen`/`onAction`) + * or `undefined` (for lifecycle hooks). Throws {@link CanvasError} when the + * canvas declares no handler for the action. + * + * @internal + */ +export async function dispatchCanvasAction( + canvas: Canvas, + sessionId: string, + params: CanvasActionInvokeParams +): Promise { + switch (params.actionName) { + case RESERVED_CANVAS_ACTIONS.open: { + const result = await canvas.onOpen({ + sessionId, + canvasId: params.canvasId, + input: params.input, + toolbar: params.toolbar, + }); + return result ?? {}; + } + case RESERVED_CANVAS_ACTIONS.focus: + case RESERVED_CANVAS_ACTIONS.close: + case RESERVED_CANVAS_ACTIONS.reload: { + const hook = + params.actionName === RESERVED_CANVAS_ACTIONS.focus + ? canvas.onFocus + : params.actionName === RESERVED_CANVAS_ACTIONS.close + ? canvas.onClose + : canvas.onReload; + if (!hook) return undefined; + const ctx: CanvasLifecycleContext = { + sessionId, + canvasId: params.canvasId, + instanceId: params.instanceId ?? "", + }; + await hook(ctx); + return undefined; + } + default: { + if (!canvas.onAction) { + throw CanvasError.noHandler(); + } + return canvas.onAction({ + sessionId, + canvasId: params.canvasId, + instanceId: params.instanceId ?? "", + actionName: params.actionName, + input: params.input, + }); + } + } +} diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 6342b6667..76ef342e2 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -31,6 +31,7 @@ import { createInternalServerRpc, registerClientSessionApiHandlers, } from "./generated/rpc.js"; +import { type CanvasActionInvokeParams, type CanvasError, dispatchCanvasAction } from "./canvas.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession, NO_RESULT_PERMISSION_V2_ERROR } from "./session.js"; import { createSessionFsAdapter, type SessionFsProvider } from "./sessionFsProvider.js"; @@ -764,6 +765,7 @@ export class CopilotClient { this.onGetTraceContext ); session.registerTools(config.tools); + session.registerCanvases(config.canvases); session.registerCommands(config.commands); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { @@ -810,6 +812,8 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + canvases: config.canvases?.map((c) => c.declaration), + requestCanvasRenderer: config.requestCanvasRenderer, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -902,6 +906,7 @@ export class CopilotClient { this.onGetTraceContext ); session.registerTools(config.tools); + session.registerCanvases(config.canvases); session.registerCommands(config.commands); session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { @@ -952,6 +957,8 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, })), + canvases: config.canvases?.map((c) => c.declaration), + requestCanvasRenderer: config.requestCanvasRenderer, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -1851,6 +1858,19 @@ export class CopilotClient { await this.handleSystemMessageTransform(params) ); + // Canvas V1.1: runtime preserves the legacy `hostExtension.invoke` + // wire method for canvas dispatches. The inner `method` discriminates; + // we route `canvas.action.invoke` to the per-session canvas registry + // and reject anything else (no other inner method is in use post-V1.1). + this.connection.onRequest( + "hostExtension.invoke", + async (params: { + sessionId: string; + request: { id?: string; method: string; params?: unknown }; + }): Promise<{ id?: string; result?: unknown; error?: CanvasError }> => + await this.handleHostExtensionInvoke(params) + ); + // Register client session API handlers. const sessions = this.sessions; registerClientSessionApiHandlers(this.connection, (sessionId) => { @@ -2036,6 +2056,51 @@ export class CopilotClient { return await session._handleSystemMessageTransform(params.sections); } + private async handleHostExtensionInvoke(params: { + sessionId: string; + request: { id?: string; method: string; params?: unknown }; + }): Promise<{ id?: string; result?: unknown; error?: CanvasError }> { + if (!params || typeof params.sessionId !== "string" || !params.request) { + throw new Error("Invalid hostExtension.invoke payload"); + } + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + const { id, method, params: inner } = params.request; + // Canvas V1.1: only `canvas.action.invoke` is in use. Other inner methods + // are dead in the V1.1 cutover; reject explicitly so misrouted calls + // don't silently no-op. + if (method !== "canvas.action.invoke") { + throw new Error(`Unsupported hostExtension.invoke method: ${method}`); + } + const actionParams = inner as CanvasActionInvokeParams; + if (!actionParams || typeof actionParams.canvasId !== "string") { + throw new Error("Invalid canvas.action.invoke params: missing canvasId"); + } + const canvas = session.getCanvas(actionParams.canvasId); + if (!canvas) { + return { + id, + error: { + code: "canvas_not_found", + message: `No canvas registered with id "${actionParams.canvasId}"`, + name: "CanvasError", + } as CanvasError, + }; + } + try { + const result = await dispatchCanvasAction(canvas, params.sessionId, actionParams); + return { id, result }; + } catch (e) { + if (e && typeof e === "object" && "code" in e && "message" in e) { + const ce = e as CanvasError; + return { id, error: { code: ce.code, message: ce.message, name: ce.name } }; + } + throw e; + } + } + // ======================================================================== // Protocol v2 backward-compatibility adapters // ======================================================================== diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index bd35c0997..bf1de4015 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -10,6 +10,20 @@ import { type ResumeSessionConfig, } from "./types.js"; +export { + Canvas, + CanvasError, + createCanvas, + type CanvasActionContext, + type CanvasAgentActionDeclaration, + type CanvasDeclaration, + type CanvasLifecycleContext, + type CanvasOpenContext, + type CanvasOpenResponse, + type CanvasOptions, + type CanvasToolbarItemDeclaration, +} from "./canvas.js"; + export type JoinSessionConfig = Omit & { onPermissionRequest?: PermissionHandler; }; diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 13c8eb1bb..57cd6f238 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -10,6 +10,19 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; +export { + Canvas, + CanvasError, + createCanvas, + type CanvasActionContext, + type CanvasAgentActionDeclaration, + type CanvasDeclaration, + type CanvasLifecycleContext, + type CanvasOpenContext, + type CanvasOpenResponse, + type CanvasOptions, + type CanvasToolbarItemDeclaration, +} from "./canvas.js"; export { defineTool, approveAll, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 6b164cb15..1c09c7a8a 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -11,6 +11,7 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; import { ConnectionError, ResponseError } from "vscode-jsonrpc/node.js"; import { createSessionRpc } from "./generated/rpc.js"; import type { ClientSessionApiHandlers } from "./generated/rpc.js"; +import type { Canvas } from "./canvas.js"; import { getTraceContext } from "./telemetry.js"; import type { CommandHandler, @@ -86,6 +87,7 @@ export class CopilotSession { private typedEventHandlers: Map void>> = new Map(); private toolHandlers: Map = new Map(); + private canvases: Map = new Map(); private commandHandlers: Map = new Map(); private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; @@ -610,6 +612,31 @@ export class CopilotSession { return this.toolHandlers.get(name); } + /** + * Registers canvas declarations + handlers for this session. + * + * @param canvases - Canvases created via `createCanvas`, or undefined to clear all canvases + * @internal Called by the SDK when creating/resuming a session with `canvases`. + */ + registerCanvases(canvases?: Canvas[]): void { + this.canvases.clear(); + if (!canvases) return; + for (const canvas of canvases) { + this.canvases.set(canvas.declaration.id, canvas); + } + } + + /** + * Retrieves a registered canvas by id. + * + * @param canvasId - The id of the canvas to retrieve + * @returns The registered Canvas if found, or undefined + * @internal Used by the SDK's `hostExtension.invoke` dispatcher. + */ + getCanvas(canvasId: string): Canvas | undefined { + return this.canvases.get(canvasId); + } + /** * Registers command handlers for this session. * diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 00cb177a6..f291e05cd 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -7,6 +7,7 @@ */ // Import and re-export generated session event types +import type { Canvas } from "./canvas.js"; import type { SessionFsProvider } from "./sessionFsProvider.js"; import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; @@ -1348,6 +1349,25 @@ export interface SessionConfig { // eslint-disable-next-line @typescript-eslint/no-explicit-any tools?: Tool[]; + /** + * Canvases contributed by this session participant (V1.1). The declaring + * connection becomes the live provider for `canvas.open|focus|close|reload` + * and `canvas.action.invoke` dispatches targeting each canvas's `id` for + * the lifetime of the connection. Re-declaring the same id on resume + * replaces the prior declaration. + */ + canvases?: Canvas[]; + + /** + * Renderer-side opt-in: when true, the runtime surfaces canvas agent tools + * (`open_canvas`, `discover_canvases`, `focus_canvas`, `close_canvas`, + * `reload_canvas`) to the model for this connection. Default off — TUI / + * headless / SDK callers stay clean unless they can actually display + * canvases. Independent of provider semantics, which are declared via + * `canvases`. + */ + requestCanvasRenderer?: boolean; + /** * Slash commands registered for this session. * When the CLI has a TUI, each command appears as `/name` for the user to invoke. @@ -1553,6 +1573,8 @@ export type ResumeSessionConfig = Pick< | "clientName" | "model" | "tools" + | "canvases" + | "requestCanvasRenderer" | "commands" | "systemMessage" | "availableTools" From d3fb4ff1c0ed60bf63a27f8f68ecad6fa3b27ddf Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 10:30:37 -0700 Subject: [PATCH 05/14] Phase 4: delete legacy HostedExtension surface V1.1 canvas dispatch is now the only path through `hostExtension.invoke`. This deletes all transitional types and the `on_hosted_extension` trait method. Removed types: - ExtensionRegistrationConfig + host_extensions / extension_roots - HostedExtensionInvokeRequest / HostedExtensionRequest - HostedExtensionResponse + Success/Error variants + helper - HostedExtensionError Removed config fields: - SessionConfig.extension_registrations / .request_host_extension - ResumeSessionConfig.extension_registrations / .request_host_extension Removed handler surface: - HandlerEvent::HostedExtension - HandlerResponse::HostedExtension - SessionHandler::on_hosted_extension default trait method - NoopHandler HostedExtension match arm The `hostExtension.invoke` JSON-RPC handler in session.rs now only accepts `canvas.action.invoke` inner method; everything else returns a structured `unsupported_method` error. Response JSON is constructed inline (`{ ok: true, result: \... }` / `{ ok: false, error: { code, message } }`). 143/143 lib tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/handler.rs | 115 +---------------------------- rust/src/session.rs | 175 +++++++++++++++++++++----------------------- rust/src/types.rs | 172 +------------------------------------------ 3 files changed, 88 insertions(+), 374 deletions(-) diff --git a/rust/src/handler.rs b/rust/src/handler.rs index cbcfc6926..40a96cf6d 100644 --- a/rust/src/handler.rs +++ b/rust/src/handler.rs @@ -8,9 +8,8 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use crate::types::{ - ElicitationRequest, ElicitationResult, ExitPlanModeData, HostedExtensionRequest, - HostedExtensionResponse, PermissionRequestData, RequestId, SessionEvent, SessionId, - ToolInvocation, ToolResult, + ElicitationRequest, ElicitationResult, ExitPlanModeData, PermissionRequestData, RequestId, + SessionEvent, SessionId, ToolInvocation, ToolResult, }; /// Events dispatched by the SDK session event loop to the handler. @@ -60,15 +59,6 @@ pub enum HandlerEvent { invocation: ToolInvocation, }, - /// The runtime requests a hosted extension action or lifecycle call. - /// Return `HandlerResponse::HostedExtension(..)`. - HostedExtension { - /// The requesting session. - session_id: SessionId, - /// Host-backed request envelope from the runtime. - request: HostedExtensionRequest, - }, - /// The CLI broadcasts an elicitation request for the provider to handle. /// Return `HandlerResponse::Elicitation(..)`. ElicitationRequest { @@ -116,8 +106,6 @@ pub enum HandlerResponse { UserInput(Option), /// Result of a tool execution. ToolResult(ToolResult), - /// Structured hosted extension response. - HostedExtension(HostedExtensionResponse), /// Elicitation result (accept/decline/cancel with optional form data). Elicitation(ElicitationResult), /// Exit plan mode decision. @@ -329,12 +317,6 @@ pub trait SessionHandler: Send + Sync + 'static { HandlerEvent::ExternalTool { invocation } => { HandlerResponse::ToolResult(self.on_external_tool(invocation).await) } - HandlerEvent::HostedExtension { - session_id, - request, - } => HandlerResponse::HostedExtension( - self.on_hosted_extension(session_id, request).await, - ), HandlerEvent::ElicitationRequest { session_id, request_id, @@ -413,23 +395,6 @@ pub trait SessionHandler: Send + Sync + 'static { }) } - /// The runtime is invoking a hosted extension implementation. - /// - /// Default: return a structured error so the runtime can surface the failure. - async fn on_hosted_extension( - &self, - _session_id: SessionId, - request: HostedExtensionRequest, - ) -> HostedExtensionResponse { - HostedExtensionResponse::error( - "hosted_extension_unavailable", - format!( - "No handler registered for hosted extension '{}'", - request.implementation_id - ), - ) - } - /// The CLI is requesting an elicitation (structured form / URL prompt). /// /// Default: cancel. @@ -527,15 +492,6 @@ impl SessionHandler for NoopHandler { } HandlerEvent::UserInput { .. } => HandlerResponse::UserInput(None), HandlerEvent::ExternalTool { .. } => HandlerResponse::NoResult, - HandlerEvent::HostedExtension { request, .. } => { - HandlerResponse::HostedExtension(HostedExtensionResponse::error( - "hosted_extension_unavailable", - format!( - "No handler registered for hosted extension '{}'", - request.implementation_id - ), - )) - } HandlerEvent::ElicitationRequest { .. } => { HandlerResponse::Elicitation(ElicitationResult { action: "cancel".to_string(), @@ -669,73 +625,6 @@ mod tests { } } - struct HostedExtensionHandler; - - #[async_trait] - impl SessionHandler for HostedExtensionHandler { - async fn on_hosted_extension( - &self, - session_id: SessionId, - request: HostedExtensionRequest, - ) -> HostedExtensionResponse { - HostedExtensionResponse::Success(crate::types::HostedExtensionSuccessResponse { - ok: true, - result: serde_json::json!({ - "sessionId": session_id, - "implementationId": request.implementation_id, - }), - }) - } - } - - #[tokio::test] - async fn default_on_hosted_extension_returns_structured_error() { - let h = DenyAllHandler; - let resp = h - .on_event(HandlerEvent::HostedExtension { - session_id: SessionId::from("s1".to_string()), - request: HostedExtensionRequest { - id: "request-1".to_string(), - implementation_id: "github-app.markdown".to_string(), - method: "canvas.action.invoke".to_string(), - params: Value::Null, - }, - }) - .await; - - match resp { - HandlerResponse::HostedExtension(HostedExtensionResponse::Error(error)) => { - assert_eq!(error.error.code, "hosted_extension_unavailable"); - assert!(error.error.message.contains("github-app.markdown")); - } - other => panic!("unexpected response: {other:?}"), - } - } - - #[tokio::test] - async fn hosted_extension_dispatches_via_default_on_event() { - let h = HostedExtensionHandler; - let resp = h - .on_event(HandlerEvent::HostedExtension { - session_id: SessionId::from("s1".to_string()), - request: HostedExtensionRequest { - id: "request-1".to_string(), - implementation_id: "github-app.markdown".to_string(), - method: "canvas.action.invoke".to_string(), - params: Value::Null, - }, - }) - .await; - - match resp { - HandlerResponse::HostedExtension(HostedExtensionResponse::Success(success)) => { - assert_eq!(success.result["sessionId"], "s1"); - assert_eq!(success.result["implementationId"], "github-app.markdown"); - } - other => panic!("unexpected response: {other:?}"), - } - } - #[tokio::test] async fn noop_handler_leaves_permission_and_external_tool_pending() { let h = NoopHandler; diff --git a/rust/src/session.rs b/rust/src/session.rs index 6ab236bbc..931dce932 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -28,11 +28,11 @@ use crate::trace_context::inject_trace_context; use crate::transforms::SystemMessageTransform; use crate::types::{ CommandContext, CommandDefinition, CommandHandler, CreateSessionResult, ElicitationRequest, - ElicitationResult, ExitPlanModeData, GetMessagesResponse, HostedExtensionInvokeRequest, - InputOptions, MessageOptions, PermissionRequestData, RequestId, ResumeSessionConfig, - SectionOverride, SessionCapabilities, SessionConfig, SessionEvent, SessionId, SetModelOptions, - SystemMessageConfig, ToolInvocation, ToolResult, ToolResultExpanded, ToolResultResponse, - TraceContext, ensure_attachment_display_names, + ElicitationResult, ExitPlanModeData, GetMessagesResponse, InputOptions, MessageOptions, + PermissionRequestData, RequestId, ResumeSessionConfig, SectionOverride, SessionCapabilities, + SessionConfig, SessionEvent, SessionId, SetModelOptions, SystemMessageConfig, ToolInvocation, + ToolResult, ToolResultExpanded, ToolResultResponse, TraceContext, + ensure_attachment_display_names, }; use crate::{Client, Error, JsonRpcResponse, SessionError, SessionEventNotification, error_codes}; @@ -1817,101 +1817,96 @@ async fn handle_request( } "hostExtension.invoke" => { - let host_request: HostedExtensionInvokeRequest = - match request.params.as_ref().and_then(|p| { - serde_json::from_value::(p.clone()).ok() - }) { - Some(host_request) => host_request, - None => { - let _ = send_error_response( - client, - request.id, - error_codes::INVALID_PARAMS, - "invalid hostExtension.invoke params", - ) - .await; - return; - } - }; + // V1.1 canvas dispatch: the only inner method accepted is + // `canvas.action.invoke`, routed through the canvas registry built + // from `SessionConfig.canvases`. + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct CanvasInvokeEnvelope { + session_id: SessionId, + request: CanvasInvokeInner, + } + #[derive(serde::Deserialize)] + struct CanvasInvokeInner { + method: String, + params: serde_json::Value, + } - // V1.1 canvas dispatch: any inner `method == "canvas.action.invoke"` - // routes through the canvas registry built from `SessionConfig.canvases`. - // Falls back to the legacy `on_hosted_extension` path otherwise (used by - // V1 hosted-extension consumers until that surface is deleted). - if host_request.request.method == "canvas.action.invoke" { - let result = match serde_json::from_value::( - host_request.request.params.clone(), - ) { - Ok(params) => { - let dispatch_start = Instant::now(); - let canvas_id = params.canvas_id.clone(); - let action_name = params.action_name.clone(); - let response = crate::canvas::dispatch_canvas_invoke( - canvas_registry, - host_request.session_id, - params, - ) - .await; - tracing::debug!( - elapsed_ms = dispatch_start.elapsed().as_millis(), - session_id = %sid, - canvas_id = %canvas_id, - action_name = %action_name, - ok = response.is_ok(), - "canvas.action.invoke dispatch" - ); - match response { - Ok(value) => crate::types::HostedExtensionResponse::Success( - crate::types::HostedExtensionSuccessResponse { - ok: true, - result: value, - }, - ), - Err(err) => crate::types::HostedExtensionResponse::Error( - crate::types::HostedExtensionErrorResponse { - ok: false, - error: crate::types::HostedExtensionError { - code: err.code, - message: err.message, - required_capabilities: Vec::new(), - }, - }, - ), - } - } - Err(err) => crate::types::HostedExtensionResponse::error( - "canvas_invalid_params", - format!("invalid canvas.action.invoke params: {err}"), - ), - }; + let envelope: CanvasInvokeEnvelope = match request + .params + .as_ref() + .and_then(|p| serde_json::from_value::(p.clone()).ok()) + { + Some(envelope) => envelope, + None => { + let _ = send_error_response( + client, + request.id, + error_codes::INVALID_PARAMS, + "invalid hostExtension.invoke params", + ) + .await; + return; + } + }; + + if envelope.request.method != "canvas.action.invoke" { + let result = serde_json::json!({ + "ok": false, + "error": { + "code": "unsupported_method", + "message": format!( + "hostExtension.invoke only supports canvas.action.invoke, got '{}'", + envelope.request.method + ), + }, + }); let rpc_response = JsonRpcResponse { jsonrpc: "2.0".to_string(), id: request.id, - result: Some(serde_json::json!(result)), + result: Some(result), error: None, }; let _ = client.send_response(&rpc_response).await; return; } - let handler_start = Instant::now(); - let response = handler - .on_event(HandlerEvent::HostedExtension { - session_id: host_request.session_id, - request: host_request.request, - }) - .await; - tracing::debug!( - elapsed_ms = handler_start.elapsed().as_millis(), - session_id = %sid, - "SessionHandler::on_hosted_extension dispatch" - ); - let result = match response { - HandlerResponse::HostedExtension(response) => serde_json::json!(response), - _ => serde_json::json!(crate::types::HostedExtensionResponse::error( - "unexpected_handler_response", - "Unexpected handler response for hostExtension.invoke", - )), + let result = match serde_json::from_value::( + envelope.request.params, + ) { + Ok(params) => { + let dispatch_start = Instant::now(); + let canvas_id = params.canvas_id.clone(); + let action_name = params.action_name.clone(); + let response = crate::canvas::dispatch_canvas_invoke( + canvas_registry, + envelope.session_id, + params, + ) + .await; + tracing::debug!( + elapsed_ms = dispatch_start.elapsed().as_millis(), + session_id = %sid, + canvas_id = %canvas_id, + action_name = %action_name, + ok = response.is_ok(), + "canvas.action.invoke dispatch" + ); + match response { + Ok(value) => serde_json::json!({ "ok": true, "result": value }), + Err(err) => serde_json::json!({ + "ok": false, + "error": { "code": err.code, "message": err.message }, + }), + } + } + Err(err) => serde_json::json!({ + "ok": false, + "error": { + "code": "canvas_invalid_params", + "message": format!("invalid canvas.action.invoke params: {err}"), + }, + }), }; let rpc_response = JsonRpcResponse { jsonrpc: "2.0".to_string(), diff --git a/rust/src/types.rs b/rust/src/types.rs index 38c6b603c..f194102d0 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -987,93 +987,6 @@ fn default_env_value_mode() -> String { /// /// This mirrors the runtime stdio contract and should be replaced by the upstream /// `github/copilot-sdk` generated type once protocol parity lands there. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[allow(missing_docs)] -pub struct ExtensionRegistrationConfig { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub extension_roots: Vec, - /// Bare extension manifests registered by the host. Each entry is a - /// `copilot-extension.json` payload (not wrapped); per-canvas - /// `contributes.canvases[].implementation: { kind, id }` routes - /// `canvas.action.invoke` dispatch. - #[serde( - default, - rename = "hostExtensions", - skip_serializing_if = "Vec::is_empty" - )] - pub host_extensions: Vec, -} - -/// Server-to-client hosted extension callback request. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[allow(missing_docs)] -pub struct HostedExtensionInvokeRequest { - pub session_id: SessionId, - pub request: HostedExtensionRequest, -} - -/// Hosted extension action/lifecycle request envelope. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[allow(missing_docs)] -pub struct HostedExtensionRequest { - pub id: String, - pub implementation_id: String, - pub method: String, - pub params: Value, -} - -/// Structured response envelope for hosted extension callbacks. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(untagged)] -#[allow(missing_docs)] -pub enum HostedExtensionResponse { - Success(HostedExtensionSuccessResponse), - Error(HostedExtensionErrorResponse), -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[allow(missing_docs)] -pub struct HostedExtensionSuccessResponse { - pub ok: bool, - pub result: Value, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[allow(missing_docs)] -pub struct HostedExtensionErrorResponse { - pub ok: bool, - pub error: HostedExtensionError, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -#[allow(missing_docs)] -pub struct HostedExtensionError { - pub code: String, - pub message: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub required_capabilities: Vec, -} - -impl HostedExtensionResponse { - #[allow(missing_docs)] - pub fn error(code: impl Into, message: impl Into) -> Self { - Self::Error(HostedExtensionErrorResponse { - ok: false, - error: HostedExtensionError { - code: code.into(), - message: message.into(), - required_capabilities: Vec::new(), - }, - }) - } -} - /// Configuration for creating a new session via the `session.create` RPC. /// /// All fields are optional — the CLI applies sensible defaults. @@ -1287,12 +1200,6 @@ pub struct SessionConfig { /// canvas id. Serialized as an array of `CanvasDeclaration` on the wire. #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] pub canvases: Vec, - /// Host-provided extension registrations for the temporary canvas POC. - #[serde(skip_serializing_if = "Option::is_none")] - pub extension_registrations: Option, - /// Ask the runtime to route hosted extension callbacks over this SDK connection. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_host_extension: 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. @@ -1363,8 +1270,6 @@ impl std::fmt::Debug for SessionConfig { ) .field("commands", &self.commands) .field("canvases", &self.canvases) - .field("extension_registrations", &self.extension_registrations) - .field("request_host_extension", &self.request_host_extension) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -1424,8 +1329,6 @@ impl Default for SessionConfig { include_sub_agent_streaming_events: None, commands: None, canvases: Vec::new(), - extension_registrations: None, - request_host_extension: None, session_fs_provider: None, handler: None, hooks_handler: None, @@ -1894,12 +1797,6 @@ pub struct ResumeSessionConfig { /// the prior entry on the runtime side. #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] pub canvases: Vec, - /// Host-provided extension registrations for the temporary canvas POC. - #[serde(skip_serializing_if = "Option::is_none")] - pub extension_registrations: Option, - /// Ask the runtime to route hosted extension callbacks over this SDK connection. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_host_extension: Option, /// Custom session filesystem provider. Required on resume when the /// [`Client`](crate::Client) was started with /// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs). @@ -1973,8 +1870,6 @@ impl std::fmt::Debug for ResumeSessionConfig { ) .field("commands", &self.commands) .field("canvases", &self.canvases) - .field("extension_registrations", &self.extension_registrations) - .field("request_host_extension", &self.request_host_extension) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -2033,8 +1928,6 @@ impl ResumeSessionConfig { include_sub_agent_streaming_events: None, commands: None, canvases: Vec::new(), - extension_registrations: None, - request_host_extension: None, session_fs_provider: None, disable_resume: None, continue_pending_work: None, @@ -3427,7 +3320,7 @@ mod tests { use super::{ Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, - ConnectionState, CustomAgentConfig, DeliveryMode, ExtensionRegistrationConfig, + ConnectionState, CustomAgentConfig, DeliveryMode, GitHubReferenceType, InfiniteSessionConfig, PermissionRequestData, ProviderConfig, ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolInvocation, ToolResult, @@ -3461,69 +3354,6 @@ mod tests { assert!(tool.skip_permission); } - #[test] - fn session_config_serializes_hosted_extension_handoff_fields() { - let cfg = SessionConfig { - extension_registrations: Some(ExtensionRegistrationConfig { - extension_roots: vec![PathBuf::from("/tmp/ext")], - host_extensions: vec![json!({ - "manifestVersion": 1, - "name": "markdown", - "publisher": "github", - "contributes": { - "canvases": [{ - "id": "markdown", - "implementation": { "kind": "native", "id": "github-app.markdown" } - }] - } - })], - }), - request_host_extension: Some(true), - ..Default::default() - }; - - let value = serde_json::to_value(cfg).expect("serialize session config"); - - assert_eq!( - value["extensionRegistrations"]["extensionRoots"], - json!(["/tmp/ext"]) - ); - assert_eq!( - value["extensionRegistrations"]["hostExtensions"][0]["contributes"]["canvases"][0]["implementation"] - ["id"], - "github-app.markdown" - ); - assert!( - value["extensionRegistrations"]["hostExtensions"][0] - .get("manifest") - .is_none(), - "hostExtensions items are bare manifests, not wrapped" - ); - assert_eq!(value["requestHostExtension"], true); - } - - #[test] - fn resume_session_config_serializes_hosted_extension_handoff_fields() { - let mut cfg = ResumeSessionConfig::new(SessionId::from("session-1")); - cfg.extension_registrations = Some(ExtensionRegistrationConfig { - extension_roots: Vec::new(), - host_extensions: vec![json!({ - "manifestVersion": 1, - "name": "markdown", - "publisher": "github" - })], - }); - cfg.request_host_extension = Some(true); - - let value = serde_json::to_value(cfg).expect("serialize resume config"); - - assert_eq!( - value["extensionRegistrations"]["hostExtensions"][0]["name"], - "markdown" - ); - assert_eq!(value["requestHostExtension"], true); - } - #[test] fn external_tool_invocation_uses_namespace_field() { let invocation: ToolInvocation = serde_json::from_value(json!({ From 90fc0df138902e684c87f258c60295865935cfc5 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 13:49:29 -0700 Subject: [PATCH 06/14] fix(rust): preserve canvases on session.create/resume wire payload `std::mem::take(&mut config.canvases)` ran before `serde_json::to_value(&config)`, leaving the field empty so `skip_serializing_if = Vec::is_empty` dropped it from the JSON-RPC payload entirely. The comment claimed `Canvas::serialize` would still emit the declarations, but the vec had already been moved out. `Canvas::serialize` delegates to `CanvasDeclaration` (handlers are not part of the wire shape), so we can just build the registry from `&config.canvases` and let serde walk the live vec. Applies to both `create_session` and `resume_session`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/session.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/rust/src/session.rs b/rust/src/session.rs index 931dce932..f78ad8e78 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -793,13 +793,11 @@ impl Client { .clone() .unwrap_or_else(|| SessionId::from(uuid::Uuid::new_v4().to_string())); config.session_id = Some(session_id.clone()); - let canvases = std::mem::take(&mut config.canvases); - let canvas_registry = Arc::new(crate::canvas::build_registry(&canvases)); + let canvas_registry = Arc::new(crate::canvas::build_registry(&config.canvases)); + // `canvases` serializes via `Canvas::serialize` -> `CanvasDeclaration` + // (handler arcs are not part of the wire shape); the registry is + // retained locally to dispatch `canvas.action.invoke` callbacks. let mut params = serde_json::to_value(&config)?; - // `canvases` is serialized via `Vec` custom impl on `config` - // (handler arcs dropped); the registry is retained locally to dispatch - // `canvas.action.invoke` callbacks. - let _ = canvases; let trace_ctx = self.resolve_trace_context().await; inject_trace_context(&mut params, &trace_ctx); @@ -927,10 +925,10 @@ impl Client { inject_transform_sections_resume(&mut config, transforms.as_ref()); } let session_id = config.session_id.clone(); - let canvases = std::mem::take(&mut config.canvases); - let canvas_registry = Arc::new(crate::canvas::build_registry(&canvases)); + let canvas_registry = Arc::new(crate::canvas::build_registry(&config.canvases)); + // See `create_session` for the rationale: `canvases` serializes via + // `Canvas::serialize` -> `CanvasDeclaration`; handlers are not on the wire. let mut params = serde_json::to_value(&config)?; - let _ = canvases; let trace_ctx = self.resolve_trace_context().await; inject_trace_context(&mut params, &trace_ctx); From 77ffb4932f0696d06637fac3a19fc3a80b8ace86 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 14:15:25 -0700 Subject: [PATCH 07/14] feat: add requestExtensions session-level opt-in Mirrors the runtime's new SessionCreateRequest.requestExtensions field (github/copilot-agent-runtime#8441, commit 3029ce07cf). When set on session.create / session.resume, the runtime wires extension management tools (extensions_reload, extensions_manage) and per-extension tool dispatch onto the session for this connection. Requires the runtime to have the EXTENSIONS experimental feature flag enabled; otherwise the runtime silently skips wiring even when the flag is true (kill-switch semantics preserved). Rust: SessionConfig + ResumeSessionConfig new request_extensions field with builder + Debug + Default. Node: SessionConfig field, ResumeSessionConfig Pick passthrough, client.ts wire passthrough for both create and resume. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 2 ++ nodejs/src/types.ts | 14 ++++++++++++++ rust/src/types.rs | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 76ef342e2..e15df6292 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -814,6 +814,7 @@ export class CopilotClient { })), canvases: config.canvases?.map((c) => c.declaration), requestCanvasRenderer: config.requestCanvasRenderer, + requestExtensions: config.requestExtensions, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -959,6 +960,7 @@ export class CopilotClient { })), canvases: config.canvases?.map((c) => c.declaration), requestCanvasRenderer: config.requestCanvasRenderer, + requestExtensions: config.requestExtensions, commands: config.commands?.map((cmd) => ({ name: cmd.name, description: cmd.description, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index f291e05cd..cacb22ccd 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1368,6 +1368,19 @@ export interface SessionConfig { */ requestCanvasRenderer?: boolean; + /** + * Extension surface opt-in: when true, the runtime wires extension + * management tools (`extensions_reload`, `extensions_manage`) and the + * per-extension tool dispatch onto the session for this connection. + * Default off — SDK callers that don't intend to expose the extension + * surface stay clean. + * + * Requires the runtime to have the `EXTENSIONS` experimental feature + * flag enabled. If the flag is off, the runtime silently skips wiring + * even when this is true. + */ + requestExtensions?: boolean; + /** * Slash commands registered for this session. * When the CLI has a TUI, each command appears as `/name` for the user to invoke. @@ -1575,6 +1588,7 @@ export type ResumeSessionConfig = Pick< | "tools" | "canvases" | "requestCanvasRenderer" + | "requestExtensions" | "commands" | "systemMessage" | "availableTools" diff --git a/rust/src/types.rs b/rust/src/types.rs index f194102d0..a18f8aa34 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1108,6 +1108,18 @@ pub struct SessionConfig { /// are declared via [`canvases`](Self::canvases). #[serde(skip_serializing_if = "Option::is_none")] pub request_canvas_renderer: Option, + /// Extension surface opt-in: when `true`, the runtime wires extension + /// management tools (`extensions_reload`, `extensions_manage`) and the + /// per-extension tool dispatch onto the session for this connection. + /// Default off — SDK callers that don't intend to expose the extension + /// surface stay clean. + /// + /// Requires the runtime to have the `EXTENSIONS` experimental feature + /// flag enabled (set via `GITHUB_COPILOT_EXPERIMENTAL_EXTENSIONS=true` + /// or the global Copilot config). If the flag is off the runtime + /// silently skips wiring even when this is `Some(true)`. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_extensions: Option, /// Skill directory paths passed through to the GitHub Copilot CLI. #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -1245,6 +1257,7 @@ impl std::fmt::Debug for SessionConfig { .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) .field("request_canvas_renderer", &self.request_canvas_renderer) + .field("request_extensions", &self.request_extensions) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("disabled_skills", &self.disabled_skills) @@ -1310,6 +1323,7 @@ impl Default for SessionConfig { request_auto_mode_switch: Some(true), request_elicitation: Some(true), request_canvas_renderer: None, + request_extensions: None, skill_directories: None, instruction_directories: None, disabled_skills: None, @@ -1537,6 +1551,14 @@ impl SessionConfig { self } + /// Extension surface opt-in: wire extension management tools and per-extension + /// tool dispatch onto the session. Requires the runtime to have the + /// `EXTENSIONS` experimental feature flag enabled. + pub fn with_request_extensions(mut self, enable: bool) -> Self { + self.request_extensions = Some(enable); + self + } + /// Set skill directory paths passed through to the CLI. pub fn with_skill_directories(mut self, paths: I) -> Self where @@ -1730,6 +1752,10 @@ pub struct ResumeSessionConfig { /// [`SessionConfig::request_canvas_renderer`]. #[serde(skip_serializing_if = "Option::is_none")] pub request_canvas_renderer: Option, + /// Extension surface opt-in on resume; see + /// [`SessionConfig::request_extensions`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_extensions: Option, /// Skill directory paths passed through to the GitHub Copilot CLI on resume. #[serde(skip_serializing_if = "Option::is_none")] pub skill_directories: Option>, @@ -1846,6 +1872,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("request_auto_mode_switch", &self.request_auto_mode_switch) .field("request_elicitation", &self.request_elicitation) .field("request_canvas_renderer", &self.request_canvas_renderer) + .field("request_extensions", &self.request_extensions) .field("skill_directories", &self.skill_directories) .field("instruction_directories", &self.instruction_directories) .field("disabled_skills", &self.disabled_skills) @@ -1910,6 +1937,7 @@ impl ResumeSessionConfig { request_auto_mode_switch: Some(true), request_elicitation: Some(true), request_canvas_renderer: None, + request_extensions: None, skill_directories: None, instruction_directories: None, disabled_skills: None, @@ -2108,6 +2136,13 @@ impl ResumeSessionConfig { self } + /// Extension surface opt-in on resume; see + /// [`SessionConfig::with_request_extensions`]. + pub fn with_request_extensions(mut self, enable: bool) -> Self { + self.request_extensions = Some(enable); + self + } + /// Set skill directory paths passed through to the CLI on resume. pub fn with_skill_directories(mut self, paths: I) -> Self where From 098b6c988e44ed2dba81bfbcce3dbdab8d1d23a2 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 14:50:23 -0700 Subject: [PATCH 08/14] Add required instance_id to CanvasOpenContext (Rust + Node) Mirrors runtime PR #8441 making agent-supplied instance_id required on canvas.open. Handlers now receive ctx.instance_id directly instead of generating their own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 13 +++++++++++++ rust/src/canvas.rs | 13 ++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index d6c53c788..7d6547dbc 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -103,6 +103,12 @@ export interface CanvasOpenContext { sessionId: string; /** Canvas id (matches the declaring `CanvasDeclaration.id`). */ canvasId: string; + /** + * Agent-supplied stable instance id. Required by the runtime on every + * `canvas.open` invocation; handlers should key their per-instance state + * off this value. + */ + instanceId: string; /** Validated `input` payload, shaped by `CanvasDeclaration.inputSchema`. */ input: unknown; /** Toolbar items declared on the canvas, passed through for convenience. */ @@ -309,9 +315,16 @@ export async function dispatchCanvasAction( ): Promise { switch (params.actionName) { case RESERVED_CANVAS_ACTIONS.open: { + if (!params.instanceId) { + throw new CanvasError( + "canvas_missing_instance_id", + "canvas.open requires an instanceId" + ); + } const result = await canvas.onOpen({ sessionId, canvasId: params.canvasId, + instanceId: params.instanceId, input: params.input, toolbar: params.toolbar, }); diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index c9cbc9b73..7969d58d8 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -118,6 +118,10 @@ pub struct CanvasOpenContext { pub session_id: SessionId, /// Canvas id (matches the declaring [`CanvasDeclaration::id`]). pub canvas_id: String, + /// Agent-supplied stable instance id. Required by the runtime on every + /// `canvas.open` invocation; handlers should key their per-instance state + /// off this value. + pub instance_id: String, /// Validated `input` payload, shaped by [`CanvasDeclaration::input_schema`]. pub input: Value, /// Toolbar items declared on the canvas, passed through for handler @@ -376,9 +380,16 @@ pub async fn dispatch_canvas_invoke( match params.action_name.as_str() { "canvas.open" => { + let instance_id = params.instance_id.ok_or_else(|| { + CanvasError::new( + "canvas_missing_instance_id", + "canvas.open requires an instanceId", + ) + })?; let ctx = CanvasOpenContext { session_id, canvas_id: params.canvas_id, + instance_id, input: params.input, toolbar: params.toolbar, }; @@ -564,7 +575,7 @@ mod tests { let params = CanvasInvokeParams { canvas_id: "echo".into(), - instance_id: None, + instance_id: Some("echo-1".into()), action_name: "canvas.open".into(), input: json!({ "x": 1 }), toolbar: None, From 7655d0b777ce6b75f920b849acdd976197dac487 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 15:39:17 -0700 Subject: [PATCH 09/14] Fix Node SDK host extension envelope shape handleHostExtensionInvoke was returning JSON-RPC-style {id, result} / {id, error}, but runtime expects HostedExtensionResponse envelope {ok: true, result} / {ok: false, error} per protocol/types.ts. This caused all extension-provided canvases to fail with canvas_invoke_malformed_response after the runtime added its defensive guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index e15df6292..e02640362 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -2061,7 +2061,7 @@ export class CopilotClient { private async handleHostExtensionInvoke(params: { sessionId: string; request: { id?: string; method: string; params?: unknown }; - }): Promise<{ id?: string; result?: unknown; error?: CanvasError }> { + }): Promise<{ ok: true; result: unknown } | { ok: false; error: CanvasError }> { if (!params || typeof params.sessionId !== "string" || !params.request) { throw new Error("Invalid hostExtension.invoke payload"); } @@ -2069,7 +2069,7 @@ export class CopilotClient { if (!session) { throw new Error(`Session not found: ${params.sessionId}`); } - const { id, method, params: inner } = params.request; + const { method, params: inner } = params.request; // Canvas V1.1: only `canvas.action.invoke` is in use. Other inner methods // are dead in the V1.1 cutover; reject explicitly so misrouted calls // don't silently no-op. @@ -2083,7 +2083,7 @@ export class CopilotClient { const canvas = session.getCanvas(actionParams.canvasId); if (!canvas) { return { - id, + ok: false, error: { code: "canvas_not_found", message: `No canvas registered with id "${actionParams.canvasId}"`, @@ -2093,11 +2093,14 @@ export class CopilotClient { } try { const result = await dispatchCanvasAction(canvas, params.sessionId, actionParams); - return { id, result }; + return { ok: true, result }; } catch (e) { if (e && typeof e === "object" && "code" in e && "message" in e) { const ce = e as CanvasError; - return { id, error: { code: ce.code, message: ce.message, name: ce.name } }; + return { + ok: false, + error: { code: ce.code, message: ce.message, name: ce.name } as CanvasError, + }; } throw e; } From bdb687fccffa2317f4d8c11ea386f68e59e7bbea Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 19:10:52 -0700 Subject: [PATCH 10/14] Add openCanvasInstances to ResumeSessionConfig Threads agent-canvas instance rehydrate from the host through both Rust and Node SDKs to the runtime's session.resume RPC. Rust: - New CanvasInstanceRehydrate struct in canvas.rs (camelCase serde). - ResumeSessionConfig gains open_canvas_instances: Vec with a with_open_canvas_instances() builder; serializes via the existing serde_json::to_value(&config) wire path in resume_session. - Debug impl includes the new field. Node: - CanvasInstanceRehydrate interface mirrored in canvas.ts, re-exported from index.ts. - ResumeSessionConfig.openCanvasInstances?: CanvasInstanceRehydrate[]. - client.ts session.resume payload forwards the field. The runtime side (copilot-agent-runtime PR #8441) consumes this via SessionResumeRequest.openCanvasInstances and rehydrateCanvasInstances(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 19 +++++++++++++++++++ nodejs/src/client.ts | 1 + nodejs/src/index.ts | 1 + nodejs/src/types.ts | 13 ++++++++++++- rust/src/canvas.rs | 25 +++++++++++++++++++++++++ rust/src/types.rs | 28 ++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 1 deletion(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index 7d6547dbc..e90cdae75 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -97,6 +97,25 @@ export interface CanvasOpenResponse { instanceId?: string; } +/** + * Identifies an extension canvas instance that the host believes is still open + * across a runtime restart. Supplied via `ResumeSessionConfig.openCanvasInstances` + * so the runtime can re-populate its in-memory instance map without re-invoking + * the extension's `onOpen`. Orphans (no matching extension/canvas in the active + * extension set) trigger a `session.canvas.closed` event with + * `reason: "rehydrate_failed"` so the host can drop the stale UI. + */ +export interface CanvasInstanceRehydrate { + /** Extension id that originally opened the canvas. */ + extensionId: string; + /** Canvas id (matches the declaring `CanvasDeclaration.id`). */ + canvasId: string; + /** Agent-supplied stable instance id from the original open. */ + instanceId: string; + /** Extension-owned URL the host last rendered, if any. */ + url?: string; +} + /** Context handed to a canvas's `onOpen` handler. */ export interface CanvasOpenContext { /** Session that requested the canvas. */ diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index e02640362..e25abcaf6 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -990,6 +990,7 @@ export class CopilotClient { infiniteSessions: config.infiniteSessions, disableResume: config.disableResume, continuePendingWork: config.continuePendingWork, + openCanvasInstances: config.openCanvasInstances, gitHubToken: config.gitHubToken, remoteSession: config.remoteSession, }); diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 57cd6f238..77acda2fd 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -17,6 +17,7 @@ export { type CanvasActionContext, type CanvasAgentActionDeclaration, type CanvasDeclaration, + type CanvasInstanceRehydrate, type CanvasLifecycleContext, type CanvasOpenContext, type CanvasOpenResponse, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index cacb22ccd..f9f427e66 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -7,7 +7,7 @@ */ // Import and re-export generated session event types -import type { Canvas } from "./canvas.js"; +import type { Canvas, CanvasInstanceRehydrate } from "./canvas.js"; import type { SessionFsProvider } from "./sessionFsProvider.js"; import type { SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; import type { CopilotSession } from "./session.js"; @@ -1639,6 +1639,17 @@ export type ResumeSessionConfig = Pick< * @default false */ continuePendingWork?: boolean; + /** + * Extension canvas instances the host believes are still open from a prior + * runtime process. Supplied on resume so the runtime can re-populate its + * in-memory canvas instance map without re-invoking each extension's + * `onOpen`. Instances whose `(extensionId, canvasId)` don't resolve in the + * active extension set produce a `session.canvas.closed` event with + * `reason: "rehydrate_failed"` so the host can drop the stale UI. Native + * host-implemented canvases (e.g. `host.*` ids) should be omitted — the + * host owns their lifecycle end-to-end without the runtime instance record. + */ + openCanvasInstances?: CanvasInstanceRehydrate[]; }; /** diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index 7969d58d8..2564b4f37 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -111,6 +111,31 @@ pub struct CanvasOpenResponse { pub instance_id: Option, } +/// Per-instance resume hint sent on `session.resume` to rebuild the runtime's +/// canvas-instance registry. The host persists open canvases across CLI +/// process restarts and hands them back here so subsequent +/// `invoke_canvas_action` dispatches find the existing instance instead of +/// erroring with `canvas_instance_not_found`. +/// +/// The handler's `on_open` is **not** re-invoked on rehydrate — the extension +/// keeps whatever state it had in its own process. Entries the runtime cannot +/// bind to a currently-declared canvas trigger a `session.canvas.closed` +/// event with `reason: "rehydrate_failed"`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CanvasInstanceRehydrate { + /// Canonical extension id that owns the canvas. + pub extension_id: String, + /// Canvas declaration id within that extension. + pub canvas_id: String, + /// Stable instance id the host originally opened the canvas under. + pub instance_id: String, + /// Optional URL recorded at the original open. Populated as-is into the + /// rebuilt instance record; not re-validated by the runtime. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + /// Context handed to [`CanvasHandler::on_open`]. #[derive(Debug, Clone)] pub struct CanvasOpenContext { diff --git a/rust/src/types.rs b/rust/src/types.rs index a18f8aa34..249454f34 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1823,6 +1823,18 @@ pub struct ResumeSessionConfig { /// the prior entry on the runtime side. #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] pub canvases: Vec, + /// Host-supplied list of canvas instances that should still be considered + /// open from a prior CLI process run, scoped to this session. The runtime + /// rebuilds its in-memory canvas-instance registry from these entries so + /// subsequent `invoke_canvas_action` dispatches succeed without the host + /// re-issuing `canvas.open`. Handler `on_open` is **not** re-invoked. + /// + /// Entries that fail to bind to a currently-declared canvas (extension + /// not loaded this session, or contribution removed) trigger a + /// `session.canvas.closed` event with `reason: "rehydrate_failed"` so the + /// host can clean up the stale panel. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub open_canvas_instances: Vec, /// Custom session filesystem provider. Required on resume when the /// [`Client`](crate::Client) was started with /// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs). @@ -1897,6 +1909,7 @@ impl std::fmt::Debug for ResumeSessionConfig { ) .field("commands", &self.commands) .field("canvases", &self.canvases) + .field("open_canvas_instances", &self.open_canvas_instances) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -1956,6 +1969,7 @@ impl ResumeSessionConfig { include_sub_agent_streaming_events: None, commands: None, canvases: Vec::new(), + open_canvas_instances: Vec::new(), session_fs_provider: None, disable_resume: None, continue_pending_work: None, @@ -1992,6 +2006,20 @@ impl ResumeSessionConfig { self } + /// Supply the list of canvas instances the host still considers open + /// from a prior CLI process run. The runtime resolves each entry's + /// `(extension_id, canvas_id)` against the canvases declared on this + /// resume and rebuilds its in-memory instance registry, so subsequent + /// `invoke_canvas_action` dispatches succeed without re-issuing + /// `canvas.open`. Handler `on_open` is **not** re-invoked. + pub fn with_open_canvas_instances( + mut self, + instances: Vec, + ) -> Self { + self.open_canvas_instances = instances; + self + } + /// Install a [`SessionFsProvider`] backing the resumed session's /// filesystem. See [`SessionConfig::with_session_fs_provider`]. pub fn with_session_fs_provider(mut self, provider: Arc) -> Self { From ffa7c34cbe0823421176d50fc87226bcf7699147 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 20:10:36 -0700 Subject: [PATCH 11/14] Scrub V1/V1.1 framing from canvas comments Rewrites doc comments on the canvas declarations, session config fields, and dispatch wiring to describe the surface as-is without versioning narrative or references to the removed hosted-extension types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 10 ++++------ nodejs/src/client.ts | 11 +++++------ nodejs/src/types.ts | 2 +- rust/src/canvas.rs | 21 +++++---------------- rust/src/lib.rs | 2 +- rust/src/session.rs | 2 +- rust/src/types.rs | 12 ++++++------ 7 files changed, 23 insertions(+), 37 deletions(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index e90cdae75..d301de142 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -3,7 +3,7 @@ *--------------------------------------------------------------------------------------------*/ /** - * Canvas V1.1 — extension-owned canvases declared via + * Extension-owned canvases declared via * `joinSession({ canvases: [createCanvas({...})] })`. * * The on-the-wire declaration shape mirrors the runtime's `CanvasDeclaration` @@ -13,11 +13,9 @@ * `canvas.action.invoke` dispatches by `(canvasId, actionName)` back to the * handlers. * - * The wire RPC method is still `hostExtension.invoke` (runtime preserves the - * legacy name); inside, `method === "canvas.action.invoke"` identifies canvas - * dispatches. The runtime synthesizes an internal - * `implementationId = "v1.1./"`, but the SDK ignores - * it and routes purely on `params.canvasId` + `params.actionName`. + * The wire RPC method is `hostExtension.invoke`; inside, + * `method === "canvas.action.invoke"` identifies canvas dispatches. The SDK + * routes purely on `params.canvasId` + `params.actionName`. */ /** diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index e25abcaf6..a93551665 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1861,10 +1861,10 @@ export class CopilotClient { await this.handleSystemMessageTransform(params) ); - // Canvas V1.1: runtime preserves the legacy `hostExtension.invoke` - // wire method for canvas dispatches. The inner `method` discriminates; + // Canvas dispatch: the runtime uses the `hostExtension.invoke` wire + // method for canvas dispatches. The inner `method` discriminates; // we route `canvas.action.invoke` to the per-session canvas registry - // and reject anything else (no other inner method is in use post-V1.1). + // and reject anything else. this.connection.onRequest( "hostExtension.invoke", async (params: { @@ -2071,9 +2071,8 @@ export class CopilotClient { throw new Error(`Session not found: ${params.sessionId}`); } const { method, params: inner } = params.request; - // Canvas V1.1: only `canvas.action.invoke` is in use. Other inner methods - // are dead in the V1.1 cutover; reject explicitly so misrouted calls - // don't silently no-op. + // Only `canvas.action.invoke` is accepted as an inner method; reject + // anything else explicitly so misrouted calls don't silently no-op. if (method !== "canvas.action.invoke") { throw new Error(`Unsupported hostExtension.invoke method: ${method}`); } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index f9f427e66..077494c26 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1350,7 +1350,7 @@ export interface SessionConfig { tools?: Tool[]; /** - * Canvases contributed by this session participant (V1.1). The declaring + * Canvases contributed by this session participant. The declaring * connection becomes the live provider for `canvas.open|focus|close|reload` * and `canvas.action.invoke` dispatches targeting each canvas's `id` for * the lifetime of the connection. Re-declaring the same id on resume diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index 2564b4f37..8429138b7 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -1,18 +1,10 @@ -//! Canvas V1.1 — extension-owned canvases declared via `joinSession({ canvases: [...] })`. +//! Extension-owned canvases declared via `joinSession({ canvases: [...] })`. //! -//! This module is the Rust mirror of the locked TypeScript wire shape committed -//! in runtime PR #8441 (`jmoseley/canvas-runtime-support`, commit `0d9535192b`). +//! This module is the Rust mirror of the TypeScript wire shape. //! -//! Status: **additive types + handler trait + Canvas/CanvasBuilder + dispatch routing**. -//! -//! The wire RPC method is still `hostExtension.invoke` (runtime keeps the -//! legacy name); inside, the inner `method == "canvas.action.invoke"` -//! identifies canvas dispatches. Runtime synthesizes -//! `implementationId = "v1.1./"`, but the SDK routes -//! purely on `params.canvasId` + `params.actionName`. -//! -//! Old hosted-extension types in `types.rs` are scheduled for deletion in a -//! follow-up edit once the host fully migrates to the new path. +//! The wire RPC method is `hostExtension.invoke`; inside, the inner +//! `method == "canvas.action.invoke"` identifies canvas dispatches. The SDK +//! routes purely on `params.canvasId` + `params.actionName`. use std::collections::HashMap; use std::sync::Arc; @@ -356,9 +348,6 @@ pub fn build_registry(canvases: &[Canvas]) -> CanvasRegistry { /// Wire-level params for `canvas.action.invoke` (the inner `method` field of /// a `hostExtension.invoke` JSON-RPC request). -/// -/// Mirrors the runtime's `HostedExtensionRequest.params` shape exactly — -/// `canvas-agent-runtime/src/core/server.ts` `dispatchCanvas*`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CanvasInvokeParams { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 96d66d556..85933c85c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -3,7 +3,7 @@ #![deny(rustdoc::broken_intra_doc_links)] #![cfg_attr(test, allow(clippy::unwrap_used))] -/// Canvas V1.1 — extension-owned canvas declarations and per-canvas handlers. +/// Extension-owned canvas declarations and per-canvas handlers. pub mod canvas; /// Bundled CLI binary extraction and caching. pub mod embeddedcli; diff --git a/rust/src/session.rs b/rust/src/session.rs index f78ad8e78..58d2a3e7b 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -1815,7 +1815,7 @@ async fn handle_request( } "hostExtension.invoke" => { - // V1.1 canvas dispatch: the only inner method accepted is + // Canvas dispatch: the only inner method accepted is // `canvas.action.invoke`, routed through the canvas registry built // from `SessionConfig.canvases`. #[derive(serde::Deserialize)] diff --git a/rust/src/types.rs b/rust/src/types.rs index 249454f34..c7abb1342 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1101,7 +1101,7 @@ pub struct SessionConfig { /// Defaults to `Some(true)` via [`SessionConfig::default`]. #[serde(skip_serializing_if = "Option::is_none")] pub request_elicitation: Option, - /// Renderer-side opt-in (V1.1): when `true`, the runtime surfaces canvas + /// Renderer-side opt-in: when `true`, the runtime surfaces canvas /// agent tools (`open_canvas`, `discover_canvases`, ...) to the model. /// Default off — TUI / headless / SDK callers stay clean unless they can /// actually display canvases. Independent of provider semantics, which @@ -1206,7 +1206,7 @@ pub struct SessionConfig { /// associated [`CommandHandler`] is called when executed. #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] pub commands: Option>, - /// Canvas V1.1 declarations. Each entry binds a [`CanvasDeclaration`] + + /// Canvas declarations. Each entry binds a [`CanvasDeclaration`] + /// [`crate::canvas::CanvasHandler`] for this session; the runtime treats /// the declaring connection as the live provider for every declared /// canvas id. Serialized as an array of `CanvasDeclaration` on the wire. @@ -1545,7 +1545,7 @@ impl SessionConfig { self } - /// Renderer-side opt-in (V1.1): surface canvas agent tools to the model. + /// Renderer-side opt-in: surface canvas agent tools to the model. pub fn with_request_canvas_renderer(mut self, enable: bool) -> Self { self.request_canvas_renderer = Some(enable); self @@ -1748,7 +1748,7 @@ pub struct ResumeSessionConfig { /// Advertise elicitation provider capability on resume. #[serde(skip_serializing_if = "Option::is_none")] pub request_elicitation: Option, - /// Renderer-side opt-in (V1.1) on resume; see + /// Renderer-side opt-in on resume; see /// [`SessionConfig::request_canvas_renderer`]. #[serde(skip_serializing_if = "Option::is_none")] pub request_canvas_renderer: Option, @@ -1818,7 +1818,7 @@ pub struct ResumeSessionConfig { /// so the resume payload re-supplies the registration. #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] pub commands: Option>, - /// Canvas V1.1 declarations to (re-)register on resume. Same semantics + /// Canvas declarations to (re-)register on resume. Same semantics /// as [`SessionConfig::canvases`]; re-declaring a canvas id replaces /// the prior entry on the runtime side. #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] @@ -2158,7 +2158,7 @@ impl ResumeSessionConfig { self } - /// Renderer-side opt-in (V1.1) on resume: surface canvas agent tools to the model. + /// Renderer-side opt-in on resume: surface canvas agent tools to the model. pub fn with_request_canvas_renderer(mut self, enable: bool) -> Self { self.request_canvas_renderer = Some(enable); self From 2c8a0d505e7134aacbf4d3c06997bb1aec4875fe Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 20:25:13 -0700 Subject: [PATCH 12/14] Address PR review comments - Rust+Node canvas dispatch: require instanceId for lifecycle verbs and custom actions (was silently defaulting to empty string). - Rust hostExtension.invoke: validate envelope.session_id matches the session handling the request; return a structured session_mismatch error envelope on mismatch. - Node handleHostExtensionInvoke: return { ok:false, error } envelopes for invalid payloads, missing sessions, and unsupported inner methods instead of throwing (which would have surfaced as JSON-RPC transport errors and broken the runtime-side contract). - Re-export CanvasInstanceRehydrate from @github/copilot-sdk/extension so extension consumers can type ResumeSessionConfig.openCanvasInstances without reaching into internal paths. - Fix canvas test that called canvas.open without instance_id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/canvas.ts | 16 ++++++++++++++-- nodejs/src/client.ts | 32 ++++++++++++++++++++++++++++---- nodejs/src/extension.ts | 1 + rust/src/canvas.rs | 13 +++++++++---- rust/src/session.rs | 21 +++++++++++++++++++++ 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index d301de142..de20988c9 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -350,6 +350,12 @@ export async function dispatchCanvasAction( case RESERVED_CANVAS_ACTIONS.focus: case RESERVED_CANVAS_ACTIONS.close: case RESERVED_CANVAS_ACTIONS.reload: { + if (!params.instanceId) { + throw new CanvasError( + "canvas_missing_instance_id", + `Lifecycle verb '${params.actionName}' requires an instanceId` + ); + } const hook = params.actionName === RESERVED_CANVAS_ACTIONS.focus ? canvas.onFocus @@ -360,7 +366,7 @@ export async function dispatchCanvasAction( const ctx: CanvasLifecycleContext = { sessionId, canvasId: params.canvasId, - instanceId: params.instanceId ?? "", + instanceId: params.instanceId, }; await hook(ctx); return undefined; @@ -369,10 +375,16 @@ export async function dispatchCanvasAction( if (!canvas.onAction) { throw CanvasError.noHandler(); } + if (!params.instanceId) { + throw new CanvasError( + "canvas_missing_instance_id", + `Action '${params.actionName}' requires an instanceId` + ); + } return canvas.onAction({ sessionId, canvasId: params.canvasId, - instanceId: params.instanceId ?? "", + instanceId: params.instanceId, actionName: params.actionName, input: params.input, }); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 4ea2bd6b5..ff4623f7a 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -2150,22 +2150,46 @@ export class CopilotClient { sessionId: string; request: { id?: string; method: string; params?: unknown }; }): Promise<{ ok: true; result: unknown } | { ok: false; error: CanvasError }> { + const invalidEnvelope = (message: string) => + ({ + ok: false as const, + error: { + code: "invalid_payload", + message, + name: "CanvasError", + } as CanvasError, + }) satisfies { ok: false; error: CanvasError }; + if (!params || typeof params.sessionId !== "string" || !params.request) { - throw new Error("Invalid hostExtension.invoke payload"); + return invalidEnvelope("Invalid hostExtension.invoke payload"); } const session = this.sessions.get(params.sessionId); if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); + return { + ok: false, + error: { + code: "session_not_found", + message: `Session not found: ${params.sessionId}`, + name: "CanvasError", + } as CanvasError, + }; } const { method, params: inner } = params.request; // Only `canvas.action.invoke` is accepted as an inner method; reject // anything else explicitly so misrouted calls don't silently no-op. if (method !== "canvas.action.invoke") { - throw new Error(`Unsupported hostExtension.invoke method: ${method}`); + return { + ok: false, + error: { + code: "unsupported_method", + message: `hostExtension.invoke only supports canvas.action.invoke, got '${method}'`, + name: "CanvasError", + } as CanvasError, + }; } const actionParams = inner as CanvasActionInvokeParams; if (!actionParams || typeof actionParams.canvasId !== "string") { - throw new Error("Invalid canvas.action.invoke params: missing canvasId"); + return invalidEnvelope("Invalid canvas.action.invoke params: missing canvasId"); } const canvas = session.getCanvas(actionParams.canvasId); if (!canvas) { diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index a5e62f49d..269de6c52 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -17,6 +17,7 @@ export { type CanvasActionContext, type CanvasAgentActionDeclaration, type CanvasDeclaration, + type CanvasInstanceRehydrate, type CanvasLifecycleContext, type CanvasOpenContext, type CanvasOpenResponse, diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index 8429138b7..7c3439ae4 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -430,13 +430,18 @@ pub async fn dispatch_canvas_invoke( } Ok(Value::Null) } - _ => { - let instance_id = params.instance_id.unwrap_or_default(); + other => { + let instance_id = params.instance_id.ok_or_else(|| { + CanvasError::new( + "canvas_missing_instance_id", + format!("Action '{other}' requires an instanceId"), + ) + })?; let ctx = CanvasActionContext { session_id, canvas_id: params.canvas_id, instance_id, - action_name: params.action_name, + action_name: other.to_string(), input: params.input, }; handler.on_action(ctx).await @@ -729,7 +734,7 @@ mod tests { SessionId::from("s1"), CanvasInvokeParams { canvas_id: "dup".into(), - instance_id: None, + instance_id: Some("inst-1".into()), action_name: "canvas.open".into(), input: Value::Null, toolbar: None, diff --git a/rust/src/session.rs b/rust/src/session.rs index 58d2a3e7b..043011ac2 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -1848,6 +1848,27 @@ async fn handle_request( } }; + if envelope.session_id != sid { + let result = serde_json::json!({ + "ok": false, + "error": { + "code": "session_mismatch", + "message": format!( + "hostExtension.invoke session id '{}' does not match this session '{}'", + envelope.session_id, sid + ), + }, + }); + let rpc_response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id, + result: Some(result), + error: None, + }; + let _ = client.send_response(&rpc_response).await; + return; + } + if envelope.request.method != "canvas.action.invoke" { let result = serde_json::json!({ "ok": false, From 4d3d7a0f5d34cafe88607f0b47c2af577412c114 Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 20:40:52 -0700 Subject: [PATCH 13/14] Apply nightly rustfmt to canvas.rs and types.rs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/canvas.rs | 3 ++- rust/src/types.rs | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index 7c3439ae4..e075c3a68 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -451,9 +451,10 @@ pub async fn dispatch_canvas_invoke( #[cfg(test)] mod tests { - use super::*; use serde_json::json; + use super::*; + #[test] fn declaration_serializes_camel_case_and_skips_none() { let decl = CanvasDeclaration { diff --git a/rust/src/types.rs b/rust/src/types.rs index e8b2400f2..0d35dc0a4 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -3383,11 +3383,11 @@ mod tests { use super::{ Attachment, AttachmentLineRange, AttachmentSelectionPosition, AttachmentSelectionRange, - ConnectionState, CustomAgentConfig, DeliveryMode, - GitHubReferenceType, InfiniteSessionConfig, PermissionRequestData, - ProviderConfig, ResumeSessionConfig, SessionConfig, SessionEvent, SessionId, - SystemMessageConfig, Tool, ToolBinaryResult, ToolInvocation, ToolResult, - ToolResultExpanded, ToolResultResponse, ensure_attachment_display_names, + ConnectionState, CustomAgentConfig, DeliveryMode, GitHubReferenceType, + InfiniteSessionConfig, PermissionRequestData, ProviderConfig, ResumeSessionConfig, + SessionConfig, SessionEvent, SessionId, SystemMessageConfig, Tool, ToolBinaryResult, + ToolInvocation, ToolResult, ToolResultExpanded, ToolResultResponse, + ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; From 23e437a834c4cae1952076a75217701678049d1f Mon Sep 17 00:00:00 2001 From: jmoseley Date: Thu, 21 May 2026 20:57:28 -0700 Subject: [PATCH 14/14] fix(rust): resolve broken intra-doc link to CanvasDeclaration The docstring on SessionOptions.canvases referenced [`CanvasDeclaration`] without a path. Since CanvasDeclaration lives in crate::canvas, rustdoc could not resolve the link and cargo doc failed under -D rustdoc::broken_intra_doc_links. Use the fully qualified path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/src/types.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/rust/src/types.rs b/rust/src/types.rs index 0d35dc0a4..890ba6264 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1206,10 +1206,12 @@ pub struct SessionConfig { /// associated [`CommandHandler`] is called when executed. #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] pub commands: Option>, - /// Canvas declarations. Each entry binds a [`CanvasDeclaration`] + - /// [`crate::canvas::CanvasHandler`] for this session; the runtime treats - /// the declaring connection as the live provider for every declared - /// canvas id. Serialized as an array of `CanvasDeclaration` on the wire. + /// Canvas declarations. Each entry binds a + /// [`CanvasDeclaration`](crate::canvas::CanvasDeclaration) + + /// [`CanvasHandler`](crate::canvas::CanvasHandler) for this session; the + /// runtime treats the declaring connection as the live provider for every + /// declared canvas id. Serialized as an array of `CanvasDeclaration` on + /// the wire. #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] pub canvases: Vec, /// Custom session filesystem provider for this session. Required when