From c823854e4e072f3c287671f6ceecc70f72d5a5bf Mon Sep 17 00:00:00 2001 From: Christopher Schleiden Date: Wed, 16 Sep 2026 10:31:35 -0700 Subject: [PATCH 1/2] Add optional PNG icons to canvas declarations Preserve canvas icon paths across Node, Rust, Go, Python, and .NET declaration APIs. Add serialization regressions and document extension-relative paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Canvas.cs | 7 +++ dotnet/test/Unit/CanvasDeclarationTests.cs | 62 ++++++++++++++++++++++ go/canvas.go | 3 ++ go/canvas_test.go | 58 ++++++++++++++++++++ nodejs/README.md | 24 +++++++++ nodejs/src/canvas.ts | 8 +++ nodejs/test/canvas.test.ts | 53 ++++++++++++++++++ python/copilot/canvas.py | 6 +++ python/test_canvas.py | 28 ++++++++++ rust/README.md | 19 +++++++ rust/src/canvas.rs | 57 ++++++++++++++++++++ 11 files changed, 325 insertions(+) create mode 100644 dotnet/test/Unit/CanvasDeclarationTests.cs create mode 100644 nodejs/test/canvas.test.ts diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs index 6bf8be984e..1369b79ea9 100644 --- a/dotnet/src/Canvas.cs +++ b/dotnet/src/Canvas.cs @@ -33,6 +33,13 @@ public sealed class CanvasDeclaration [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; + /// + /// Optional PNG path for the canvas icon. For extensions, the runtime resolves + /// relative paths relative to extension.mjs. + /// + [JsonPropertyName("icon")] + public string? Icon { get; set; } + /// JSON Schema for the input payload accepted by canvas.open. [JsonPropertyName("inputSchema")] public JsonElement? InputSchema { get; set; } diff --git a/dotnet/test/Unit/CanvasDeclarationTests.cs b/dotnet/test/Unit/CanvasDeclarationTests.cs new file mode 100644 index 0000000000..2db5b768b6 --- /dev/null +++ b/dotnet/test/Unit/CanvasDeclarationTests.cs @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Text.Json; +using System.Text.Json.Serialization; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public class CanvasDeclarationTests +{ + [Fact] + public void SerializesIconPath() + { + var declaration = new CanvasDeclaration + { + Id = "counter", + DisplayName = "Counter", + Description = "Count things", + Icon = "icons/counter.png", + }; + var serialized = JsonSerializer.Serialize(declaration, CanvasDeclarationTestJsonContext.Default.CanvasDeclaration); + using var doc = JsonDocument.Parse(serialized); + + Assert.Equal("icons/counter.png", doc.RootElement.GetProperty("icon").GetString()); + } + + [Fact] + public void RoundtripsIconPath() + { + const string json = """ + {"id":"counter","displayName":"Counter","description":"Count things","icon":"icons/counter.png"} + """; + var declaration = JsonSerializer.Deserialize(json, CanvasDeclarationTestJsonContext.Default.CanvasDeclaration); + Assert.NotNull(declaration); + Assert.Equal("icons/counter.png", declaration.Icon); + var serialized = JsonSerializer.Serialize(declaration, CanvasDeclarationTestJsonContext.Default.CanvasDeclaration); + using var doc = JsonDocument.Parse(serialized); + + Assert.Equal("icons/counter.png", doc.RootElement.GetProperty("icon").GetString()); + } + + [Fact] + public void OmitsUnspecifiedIcon() + { + var declaration = new CanvasDeclaration + { + Id = "counter", + DisplayName = "Counter", + Description = "Count things", + }; + var serialized = JsonSerializer.Serialize(declaration, CanvasDeclarationTestJsonContext.Default.CanvasDeclaration); + using var doc = JsonDocument.Parse(serialized); + + Assert.False(doc.RootElement.TryGetProperty("icon", out _)); + } +} + +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(CanvasDeclaration))] +internal partial class CanvasDeclarationTestJsonContext : JsonSerializerContext; diff --git a/go/canvas.go b/go/canvas.go index e31598bb1e..8f9fd81937 100644 --- a/go/canvas.go +++ b/go/canvas.go @@ -24,6 +24,9 @@ type CanvasDeclaration struct { DisplayName string `json:"displayName"` // Description is a short, single-sentence description shown to the agent in canvas catalogs. Description string `json:"description"` + // Icon is an optional PNG path for the canvas icon. For extensions, the runtime + // resolves relative paths relative to extension.mjs. + Icon *string `json:"icon,omitempty"` // InputSchema is the JSON Schema for the `input` payload accepted by `canvas.open`. InputSchema map[string]any `json:"inputSchema,omitzero"` // Actions are the agent-callable actions this canvas exposes. diff --git a/go/canvas_test.go b/go/canvas_test.go index 3fdd2facc4..684437795c 100644 --- a/go/canvas_test.go +++ b/go/canvas_test.go @@ -67,6 +67,64 @@ func TestCanvasDeclaration_OmitsEmptyActions(t *testing.T) { } } +func TestCanvasDeclaration_RoundtripsIcon(t *testing.T) { + data := []byte(`{"id":"counter","displayName":"Counter","description":"Count things","icon":"icons/counter.png"}`) + var decl CanvasDeclaration + if err := json.Unmarshal(data, &decl); err != nil { + t.Fatalf("unmarshal declaration failed: %v", err) + } + if decl.Icon == nil || *decl.Icon != "icons/counter.png" { + t.Fatalf("expected icon path on declaration, got %v", decl.Icon) + } + encoded, err := json.Marshal(decl) + if err != nil { + t.Fatalf("marshal declaration failed: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal payload failed: %v", err) + } + if decoded["icon"] != "icons/counter.png" { + t.Fatalf("expected icon path to be preserved, got %v", decoded["icon"]) + } +} + +func TestCanvasDeclaration_SerializesIcon(t *testing.T) { + icon := "icons/counter.png" + decl := CanvasDeclaration{ + ID: "counter", + DisplayName: "Counter", + Description: "Count things", + Icon: &icon, + } + data, err := json.Marshal(decl) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if decoded["icon"] != icon { + t.Fatalf("expected icon path to be preserved, got %v", decoded["icon"]) + } +} + +func TestCanvasDeclaration_OmitsUnspecifiedIcon(t *testing.T) { + decl := CanvasDeclaration{ID: "counter", DisplayName: "Counter", Description: "Count things"} + data, err := json.Marshal(decl) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if _, present := decoded["icon"]; present { + t.Fatalf("icon should be omitted when nil, got %v", decoded["icon"]) + } +} + func TestCanvasHandlerDefaults_OnAction_ReturnsNoHandler(t *testing.T) { d := CanvasHandlerDefaults{} _, err := d.OnAction(context.Background(), rpc.CanvasProviderInvokeActionRequest{}) diff --git a/nodejs/README.md b/nodejs/README.md index a6e577be93..0fd5b776ea 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -96,6 +96,30 @@ tool name is `-`. For `availableTools` and the raw `mcp:-` form. For `customAgents[].tools` and `defaultAgent.excludedTools`, use `-` directly. +## Canvas icons + +Canvas authoring is experimental. Set the optional `icon` field on +`createCanvas` to a PNG file path. For extensions, the runtime resolves relative +paths relative to `extension.mjs`, not the current working directory. Include +the PNG with your extension; the SDK forwards the path unchanged. + +```typescript +import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +const counter = createCanvas({ + id: "counter", + displayName: "Counter", + description: "Count things", + icon: "icons/counter.png", + open: () => ({ url: "https://example.com/counter" }), +}); + +await joinSession({ canvases: [counter] }); +``` + +Omit `icon` to declare a canvas without a custom icon. The icon belongs to the +canvas declaration, not an individual open result. + ## API Reference ### CopilotClient diff --git a/nodejs/src/canvas.ts b/nodejs/src/canvas.ts index aeb1f00ec4..0624379777 100644 --- a/nodejs/src/canvas.ts +++ b/nodejs/src/canvas.ts @@ -67,6 +67,11 @@ export interface CanvasDeclaration { displayName: string; /** Short, single-sentence description shown to the agent in canvas catalogs. */ description: string; + /** + * Optional PNG path for the canvas icon. For extensions, relative paths are + * resolved by the runtime relative to `extension.mjs`. + */ + icon?: string; /** Optional JSON Schema for the `input` payload accepted by `canvas.open`. */ inputSchema?: CanvasJsonSchema; /** Agent-invocable actions exposed via `invoke_canvas_action`. */ @@ -111,6 +116,8 @@ export interface CanvasOptions { displayName: string; /** @see CanvasDeclaration.description */ description: string; + /** @see CanvasDeclaration.icon */ + icon?: string; /** @see CanvasDeclaration.inputSchema */ inputSchema?: CanvasJsonSchema; /** @@ -165,6 +172,7 @@ export class Canvas { id: options.id, displayName: options.displayName, description: options.description, + icon: options.icon, inputSchema: options.inputSchema, actions: wireActions, }; diff --git a/nodejs/test/canvas.test.ts b/nodejs/test/canvas.test.ts new file mode 100644 index 0000000000..6bb90e908a --- /dev/null +++ b/nodejs/test/canvas.test.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createCanvas, type CanvasDeclaration } from "../src/canvas.js"; + +describe("createCanvas", () => { + it.each(["icons/counter.png", resolve("icons", "counter.png")])( + "preserves the icon path %s in the wire declaration", + (icon) => { + const action = { name: "increment", description: "Increment the counter" }; + const declaration: CanvasDeclaration = { + id: "counter", + displayName: "Counter", + description: "Count things", + icon, + inputSchema: { type: "object" }, + actions: [action], + }; + const open = () => ({ url: "https://example.test/counter" }); + const handler = () => ({ count: 1 }); + const canvas = createCanvas({ + ...declaration, + icon, + actions: [{ ...action, handler }], + open, + }); + + expect(canvas.declaration.icon).toBe(icon); + expect(JSON.parse(JSON.stringify(canvas.declaration))).toEqual(declaration); + expect(canvas.open).toBe(open); + expect(canvas.actionHandlers.get("increment")).toBe(handler); + } + ); + + it("omits an unspecified icon from the wire declaration", () => { + const canvas = createCanvas({ + id: "counter", + displayName: "Counter", + description: "Count things", + open: () => ({ url: "https://example.test/counter" }), + }); + + expect(canvas.declaration.icon).toBeUndefined(); + expect(JSON.parse(JSON.stringify(canvas.declaration))).toEqual({ + id: "counter", + displayName: "Counter", + description: "Count things", + }); + }); +}); diff --git a/python/copilot/canvas.py b/python/copilot/canvas.py index 9b8dec5258..190cf42f1d 100644 --- a/python/copilot/canvas.py +++ b/python/copilot/canvas.py @@ -119,6 +119,10 @@ class CanvasDeclaration: actions: list[CanvasAction] | None = None """Agent-callable actions this canvas exposes.""" + icon: str | None = None + """Optional PNG icon path. For extensions, the runtime resolves relative paths + relative to ``extension.mjs``.""" + def to_dict(self) -> dict[str, Any]: result: dict[str, Any] = { "id": self.id, @@ -129,6 +133,8 @@ def to_dict(self) -> dict[str, Any]: result["inputSchema"] = self.input_schema if self.actions is not None: result["actions"] = [action.to_dict() for action in self.actions] + if self.icon is not None: + result["icon"] = self.icon return result diff --git a/python/test_canvas.py b/python/test_canvas.py index 684cef6b79..294d32330e 100644 --- a/python/test_canvas.py +++ b/python/test_canvas.py @@ -63,6 +63,34 @@ def test_canvas_declaration_serializes_input_schema_and_actions(): assert payload["actions"] == [action.to_dict()] +@pytest.mark.parametrize("icon", [None, "icons/counter.png"]) +def test_canvas_declaration_serializes_optional_icon(icon): + decl = CanvasDeclaration( + id="counter", + display_name="Counter", + description="Count things", + icon=icon, + ) + payload = decl.to_dict() + if icon is None: + assert "icon" not in payload + else: + assert payload["icon"] == icon + + +def test_canvas_declaration_preserves_positional_arguments(): + action = CanvasAction(name="increment") + decl = CanvasDeclaration("counter", "Counter", "Count things", {"type": "object"}, [action]) + + assert decl.to_dict() == { + "id": "counter", + "displayName": "Counter", + "description": "Count things", + "inputSchema": {"type": "object"}, + "actions": [action.to_dict()], + } + + def test_extension_info_serializes(): info = ExtensionInfo(source="github-app", name="my-ext") assert info.to_dict() == {"source": "github-app", "name": "my-ext"} diff --git a/rust/README.md b/rust/README.md index 11d9637b22..5ff265f9be 100644 --- a/rust/README.md +++ b/rust/README.md @@ -37,6 +37,25 @@ tool name is `-`. For `available_tools` and or the raw `mcp:-` form. For `custom_agents[].tools` and `default_agent.excluded_tools`, use `-` directly. +## Canvas icons + +Canvas authoring is experimental. Set the optional PNG path with +`CanvasDeclaration::with_icon` or the `icon: Option` field: + +```rust +use github_copilot_sdk::canvas::CanvasDeclaration; + +let canvas = CanvasDeclaration::new("counter", "Counter", "Count things") + .with_icon("icons/counter.png"); +``` + +Supply the declaration through `SessionConfig::with_canvases` or +`ResumeSessionConfig::with_canvases`, alongside your canvas handler. For +extensions, the runtime resolves relative icon paths relative to +`extension.mjs`, not the current working directory. Include the PNG with your +extension; the SDK forwards the path unchanged. Omit the icon (the default is +`None`) to declare a canvas without a custom icon. + ## Architecture ```text diff --git a/rust/src/canvas.rs b/rust/src/canvas.rs index ddb92a11e6..50b1bdabf5 100644 --- a/rust/src/canvas.rs +++ b/rust/src/canvas.rs @@ -42,6 +42,10 @@ pub struct CanvasDeclaration { pub display_name: String, /// Short, single-sentence description shown to the agent in canvas catalogs. pub description: String, + /// Optional PNG path for the canvas icon. For extensions, the runtime resolves + /// relative paths relative to `extension.mjs`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, /// JSON Schema for the `input` payload accepted by `canvas.open`. #[serde(default, skip_serializing_if = "Option::is_none")] pub input_schema: Option, @@ -61,6 +65,7 @@ impl CanvasDeclaration { id: id.into(), display_name: display_name.into(), description: description.into(), + icon: None, input_schema: None, actions: None, } @@ -71,6 +76,13 @@ impl CanvasDeclaration { self.description = description.into(); self } + + /// Set the optional PNG icon path. For extensions, relative paths are resolved + /// by the runtime relative to `extension.mjs`. + pub fn with_icon(mut self, icon: impl Into) -> Self { + self.icon = Some(icon.into()); + self + } } /// Structured error returned from canvas handlers. @@ -200,12 +212,57 @@ mod tests { } } + #[test] + fn declaration_roundtrips_icon_path() { + let value = json!({ + "id": "counter", + "displayName": "Counter", + "description": "Count things", + "icon": "icons/counter.png", + }); + let decl: CanvasDeclaration = serde_json::from_value(value.clone()).unwrap(); + + assert_eq!(serde_json::to_value(decl).unwrap(), value); + } + + #[test] + fn declaration_builder_preserves_icon_path() { + let decl = CanvasDeclaration::new("counter", "Counter", "Count things") + .with_icon("icons/counter.png"); + + assert_eq!(decl.icon.as_deref(), Some("icons/counter.png")); + assert_eq!( + serde_json::to_value(decl).unwrap()["icon"], + "icons/counter.png" + ); + } + + #[test] + fn declaration_omits_unspecified_icon() { + let value = json!({ + "id": "counter", + "displayName": "Counter", + "description": "Count things", + }); + let decl: CanvasDeclaration = serde_json::from_value(value.clone()).unwrap(); + + assert!(decl.icon.is_none()); + assert!(CanvasDeclaration::default().icon.is_none()); + assert_eq!(serde_json::to_value(decl).unwrap(), value); + assert_eq!( + serde_json::to_value(CanvasDeclaration::new("counter", "Counter", "Count things")) + .unwrap(), + value, + ); + } + #[test] fn declaration_serializes_camel_case_and_skips_none() { let decl = CanvasDeclaration { id: "counter".to_string(), display_name: "Counter".to_string(), description: "Count things".to_string(), + icon: None, input_schema: None, actions: Some(vec![CanvasAction { name: "increment".to_string(), From 9f1ce7bfeb8b8e7fd17a305879b0841fa0f2d5f9 Mon Sep 17 00:00:00 2001 From: Christopher Schleiden Date: Wed, 16 Sep 2026 10:44:21 -0700 Subject: [PATCH 2/2] Fix Rust version snapshots under Git Bash Read the package version relative to its directory instead of embedding a shell path in JavaScript. Cover Windows paths and directories with spaces and apostrophes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/test/rust-version-snapshot.test.ts | 58 +++++++++++++++++++ rust/scripts/snapshot-bundled-cli-version.sh | 2 +- .../snapshot-bundled-in-process-version.sh | 2 +- 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 nodejs/test/rust-version-snapshot.test.ts diff --git a/nodejs/test/rust-version-snapshot.test.ts b/nodejs/test/rust-version-snapshot.test.ts new file mode 100644 index 0000000000..13d0853401 --- /dev/null +++ b/nodejs/test/rust-version-snapshot.test.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, onTestFinished } from "vitest"; + +// Windows needs Git Bash's path conversion, not the WSL bash executable. +const bash = + process.platform === "win32" + ? join(process.env.ProgramFiles ?? "C:\\Program Files", "Git", "bin", "bash.exe") + : "bash"; + +describe.each(["snapshot-bundled-cli-version.sh", "snapshot-bundled-in-process-version.sh"])( + "%s", + (scriptName) => { + it.each(["copilot-snapshot-", "copilot sdk's snapshot-"])( + "reads the version under %s", + (prefix) => { + const directory = mkdtempSync(join(tmpdir(), prefix)); + onTestFinished(() => rmSync(directory, { recursive: true, force: true })); + const packageFile = join(directory, "package.json"); + writeFileSync(packageFile, JSON.stringify({ copilotCliVersion: "1.2.3" })); + + const script = readFileSync( + new URL(`../../rust/scripts/${scriptName}`, import.meta.url), + "utf8" + ); + const versionAssignment = script.match(/^VERSION=.*$/m)?.[0]; + expect(versionAssignment).toBeDefined(); + + const result = spawnSync( + bash, + [ + "-euc", + [ + process.platform === "win32" + ? 'PACKAGE_FILE="$(cygpath -u "$1")"' + : 'PACKAGE_FILE="$1"', + versionAssignment, + 'printf "%s" "$VERSION"', + ].join("\n"), + "version-test", + packageFile, + ], + { encoding: "utf8", timeout: 10000 } + ); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe("1.2.3"); + } + ); + } +); diff --git a/rust/scripts/snapshot-bundled-cli-version.sh b/rust/scripts/snapshot-bundled-cli-version.sh index 0045f5e6c8..4043135b8e 100755 --- a/rust/scripts/snapshot-bundled-cli-version.sh +++ b/rust/scripts/snapshot-bundled-cli-version.sh @@ -25,7 +25,7 @@ if [[ ! -f "${PACKAGE_FILE}" ]]; then exit 1 fi -VERSION="$(node -e "console.log(require('${PACKAGE_FILE}').copilotCliVersion)")" +VERSION="$(cd "$(dirname "${PACKAGE_FILE}")" && node -e "console.log(require('./package.json').copilotCliVersion)")" if [[ -z "${VERSION}" ]]; then echo "error: could not read copilotCliVersion from ${PACKAGE_FILE}" >&2 exit 1 diff --git a/rust/scripts/snapshot-bundled-in-process-version.sh b/rust/scripts/snapshot-bundled-in-process-version.sh index 9fe2298c78..53c94021d2 100755 --- a/rust/scripts/snapshot-bundled-in-process-version.sh +++ b/rust/scripts/snapshot-bundled-in-process-version.sh @@ -16,7 +16,7 @@ if [[ ! -f "${PACKAGE_FILE}" ]]; then exit 1 fi -VERSION="$(node -e "console.log(require('${PACKAGE_FILE}').copilotCliVersion)")" +VERSION="$(cd "$(dirname "${PACKAGE_FILE}")" && node -e "console.log(require('./package.json').copilotCliVersion)")" if [[ -z "${VERSION}" ]]; then echo "error: could not read copilotCliVersion from ${PACKAGE_FILE}" >&2 exit 1