Skip to main content

github_copilot_sdk/
canvas.rs

1//! Canvas declarations, provider callbacks, and host-side canvas RPC types.
2//!
3//! <div class="warning">
4//!
5//! **Experimental.** Canvas types are part of an experimental wire-protocol surface
6//! and may change or be removed in future SDK or CLI releases.
7//!
8//! </div>
9
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::generated::api_types::CanvasAction;
15
16/// JSON Schema object used for canvas inputs and canvas-scoped tools.
17///
18/// <div class="warning">
19///
20/// **Experimental.** This type is part of an experimental wire-protocol surface
21/// and may change or be removed in future SDK or CLI releases.
22///
23/// </div>
24pub type CanvasJsonSchema = serde_json::Map<String, Value>;
25
26/// Declarative metadata for a single canvas, sent over the wire on
27/// `session.create` / `session.resume`.
28///
29/// <div class="warning">
30///
31/// **Experimental.** This type is part of an experimental wire-protocol surface
32/// and may change or be removed in future SDK or CLI releases.
33///
34/// </div>
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37#[non_exhaustive]
38pub struct CanvasDeclaration {
39    /// Canvas identifier, unique within the declaring connection.
40    pub id: String,
41    /// Human-readable name shown in host UI and canvas pickers.
42    pub display_name: String,
43    /// Short, single-sentence description shown to the agent in canvas catalogs.
44    pub description: String,
45    /// JSON Schema for the `input` payload accepted by `canvas.open`.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub input_schema: Option<Value>,
48    /// Agent-callable actions this canvas exposes.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub actions: Option<Vec<CanvasAction>>,
51}
52
53impl CanvasDeclaration {
54    /// Construct a canvas declaration with the required fields set.
55    pub fn new(
56        id: impl Into<String>,
57        display_name: impl Into<String>,
58        description: impl Into<String>,
59    ) -> Self {
60        Self {
61            id: id.into(),
62            display_name: display_name.into(),
63            description: description.into(),
64            input_schema: None,
65            actions: None,
66        }
67    }
68
69    /// Set the description surfaced in discovery and agent context.
70    pub fn with_description(mut self, description: impl Into<String>) -> Self {
71        self.description = description.into();
72        self
73    }
74}
75
76/// Structured error returned from canvas handlers.
77///
78/// <div class="warning">
79///
80/// **Experimental.** This type is part of an experimental wire-protocol surface
81/// and may change or be removed in future SDK or CLI releases.
82///
83/// </div>
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "camelCase")]
86pub struct CanvasError {
87    /// Machine-readable error code.
88    pub code: String,
89    /// Human-readable message.
90    pub message: String,
91}
92
93impl std::fmt::Display for CanvasError {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        write!(f, "{}: {}", self.code, self.message)
96    }
97}
98
99impl std::error::Error for CanvasError {}
100
101impl CanvasError {
102    /// Construct a new error envelope with the given code and message.
103    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
104        Self {
105            code: code.into(),
106            message: message.into(),
107        }
108    }
109
110    /// Default error returned when a custom action has no handler.
111    pub fn no_handler() -> Self {
112        Self::new(
113            "canvas_action_no_handler",
114            "No handler implemented for this canvas action",
115        )
116    }
117}
118
119/// Result alias for canvas handler methods.
120///
121/// <div class="warning">
122///
123/// **Experimental.** This type is part of an experimental wire-protocol surface
124/// and may change or be removed in future SDK or CLI releases.
125///
126/// </div>
127pub type CanvasResult<T> = Result<T, CanvasError>;
128
129/// Provider-side canvas lifecycle handler.
130///
131/// <div class="warning">
132///
133/// **Experimental.** This trait is part of an experimental wire-protocol surface
134/// and may change or be removed in future SDK or CLI releases.
135///
136/// </div>
137///
138/// A session installs a single [`CanvasHandler`] (via
139/// [`SessionConfig::with_canvas_handler`](crate::types::SessionConfig::with_canvas_handler)).
140/// The handler receives every inbound `canvas.open` / `canvas.close` /
141/// `canvas.action.invoke` JSON-RPC request the runtime issues for this
142/// session and decides — typically by inspecting
143/// [`CanvasProviderOpenRequest::canvas_id`](crate::rpc::CanvasProviderOpenRequest::canvas_id)
144/// — which application-side canvas should handle the call.
145///
146/// The SDK does not maintain a per-canvas registry; multiplexing across
147/// declared canvases is the implementor's responsibility.
148#[async_trait]
149pub trait CanvasHandler: Send + Sync {
150    /// Open a new canvas instance.
151    async fn on_open(
152        &self,
153        ctx: crate::generated::api_types::CanvasProviderOpenRequest,
154    ) -> CanvasResult<crate::generated::api_types::CanvasProviderOpenResult>;
155
156    /// Handle a non-lifecycle action declared by the canvas.
157    async fn on_action(
158        &self,
159        _ctx: crate::generated::api_types::CanvasProviderInvokeActionRequest,
160    ) -> CanvasResult<Value> {
161        Err(CanvasError::no_handler())
162    }
163
164    /// Canvas was closed by the user or agent.
165    async fn on_close(
166        &self,
167        _ctx: crate::generated::api_types::CanvasProviderCloseRequest,
168    ) -> CanvasResult<()> {
169        Ok(())
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use serde_json::json;
176
177    use super::*;
178    use crate::generated::api_types::{
179        CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, CanvasProviderOpenResult,
180    };
181    use crate::types::SessionId;
182
183    struct EchoHandler;
184
185    #[async_trait]
186    impl CanvasHandler for EchoHandler {
187        async fn on_open(
188            &self,
189            ctx: CanvasProviderOpenRequest,
190        ) -> CanvasResult<CanvasProviderOpenResult> {
191            Ok(CanvasProviderOpenResult {
192                url: Some(format!("https://example.test/{}", ctx.canvas_id)),
193                title: Some("Echo".to_string()),
194                status: Some("ready".to_string()),
195            })
196        }
197
198        async fn on_action(&self, ctx: CanvasProviderInvokeActionRequest) -> CanvasResult<Value> {
199            Ok(json!({ "echoed": ctx.action_name, "input": ctx.input }))
200        }
201    }
202
203    #[test]
204    fn declaration_serializes_camel_case_and_skips_none() {
205        let decl = CanvasDeclaration {
206            id: "counter".to_string(),
207            display_name: "Counter".to_string(),
208            description: "Count things".to_string(),
209            input_schema: None,
210            actions: Some(vec![CanvasAction {
211                name: "increment".to_string(),
212                description: Some("bump".to_string()),
213                input_schema: None,
214            }]),
215        };
216
217        let value = serde_json::to_value(&decl).unwrap();
218
219        assert_eq!(value["id"], "counter");
220        assert_eq!(value["displayName"], "Counter");
221        assert_eq!(value["description"], "Count things");
222        assert_eq!(value["actions"][0]["name"], "increment");
223    }
224
225    #[tokio::test]
226    async fn handler_on_open_returns_response() {
227        let handler = EchoHandler;
228        let response = handler
229            .on_open(CanvasProviderOpenRequest {
230                session_id: SessionId::from("s1"),
231                extension_id: "project:echo".to_string(),
232                canvas_id: "echo".to_string(),
233                instance_id: "echo-1".to_string(),
234                input: Some(json!({ "x": 1 })),
235                host: None,
236                session: None,
237            })
238            .await
239            .unwrap();
240
241        assert_eq!(response.url.as_deref(), Some("https://example.test/echo"));
242        assert_eq!(response.title.as_deref(), Some("Echo"));
243        assert_eq!(response.status.as_deref(), Some("ready"));
244    }
245
246    #[tokio::test]
247    async fn handler_on_action_returns_value() {
248        let handler = EchoHandler;
249        let result = handler
250            .on_action(CanvasProviderInvokeActionRequest {
251                session_id: SessionId::from("s1"),
252                extension_id: "project:echo".to_string(),
253                canvas_id: "echo".to_string(),
254                instance_id: "inst-1".to_string(),
255                action_name: "shout".to_string(),
256                input: Some(json!("hi")),
257                host: None,
258                session: None,
259            })
260            .await
261            .unwrap();
262
263        assert_eq!(result["echoed"], "shout");
264        assert_eq!(result["input"], "hi");
265    }
266
267    #[tokio::test]
268    async fn default_on_action_returns_no_handler_error() {
269        struct OpenOnly;
270        #[async_trait]
271        impl CanvasHandler for OpenOnly {
272            async fn on_open(
273                &self,
274                _ctx: CanvasProviderOpenRequest,
275            ) -> CanvasResult<CanvasProviderOpenResult> {
276                Ok(CanvasProviderOpenResult {
277                    url: None,
278                    title: None,
279                    status: None,
280                })
281            }
282        }
283
284        let err = OpenOnly
285            .on_action(CanvasProviderInvokeActionRequest {
286                session_id: SessionId::from("s1"),
287                extension_id: "project:open-only".to_string(),
288                canvas_id: "x".to_string(),
289                instance_id: "x-1".to_string(),
290                action_name: "anything".to_string(),
291                input: Some(Value::Null),
292                host: None,
293                session: None,
294            })
295            .await
296            .unwrap_err();
297
298        assert_eq!(err.code, "canvas_action_no_handler");
299    }
300}