Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions dotnet/src/Canvas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ public sealed class CanvasDeclaration
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;

/// <summary>
/// Optional PNG path for the canvas icon. For extensions, the runtime resolves
/// relative paths relative to <c>extension.mjs</c>.
/// </summary>
[JsonPropertyName("icon")]
public string? Icon { get; set; }

/// <summary>JSON Schema for the <c>input</c> payload accepted by <c>canvas.open</c>.</summary>
[JsonPropertyName("inputSchema")]
public JsonElement? InputSchema { get; set; }
Expand Down
62 changes: 62 additions & 0 deletions dotnet/test/Unit/CanvasDeclarationTests.cs
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions go/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions go/canvas_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
24 changes: 24 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,30 @@ tool name is `<server-key>-<tool-name>`. For `availableTools` and
the raw `mcp:<server-key>-<tool-name>` form. For `customAgents[].tools` and
`defaultAgent.excludedTools`, use `<server-key>-<tool-name>` 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
Expand Down
8 changes: 8 additions & 0 deletions nodejs/src/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -111,6 +116,8 @@ export interface CanvasOptions {
displayName: string;
/** @see CanvasDeclaration.description */
description: string;
/** @see CanvasDeclaration.icon */
icon?: string;
/** @see CanvasDeclaration.inputSchema */
inputSchema?: CanvasJsonSchema;
/**
Expand Down Expand Up @@ -165,6 +172,7 @@ export class Canvas {
id: options.id,
displayName: options.displayName,
description: options.description,
icon: options.icon,
inputSchema: options.inputSchema,
actions: wireActions,
};
Expand Down
53 changes: 53 additions & 0 deletions nodejs/test/canvas.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
58 changes: 58 additions & 0 deletions nodejs/test/rust-version-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
);
}
);
6 changes: 6 additions & 0 deletions python/copilot/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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


Expand Down
28 changes: 28 additions & 0 deletions python/test_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
Loading
Loading