diff --git a/dotnet/src/Canvas.cs b/dotnet/src/Canvas.cs
index b4e63f1b3..6bf8be984 100644
--- a/dotnet/src/Canvas.cs
+++ b/dotnet/src/Canvas.cs
@@ -57,6 +57,32 @@ public sealed class ExtensionInfo
public string Name { get; set; } = string.Empty;
}
+///
+/// Stable identity for a host/SDK connection that supplies built-in canvases.
+///
+///
+/// When set on session create or resume, the runtime uses
+/// verbatim as the agent-facing canvas extension id, so canvases declared on a
+/// control connection survive stdio reconnect and CLI process restart instead
+/// of being re-keyed to a per-connection id. The id is opaque to the runtime; a
+/// per-window-stable value such as app:builtin:<windowId> is
+/// recommended. An id beginning with connection: is reserved and ignored
+/// by the runtime.
+///
+[Experimental(Diagnostics.Experimental)]
+public sealed class CanvasProviderIdentity
+{
+ ///
+ /// Opaque, stable provider id used verbatim as the canvas extension id.
+ ///
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ /// Optional display name surfaced as the canvas extension name.
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+}
+
/// Structured exception returned from canvas handlers.
///
/// Throw this from implementations to surface a
diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs
index 9fbe8c5a7..1136d2744 100644
--- a/dotnet/src/Client.cs
+++ b/dotnet/src/Client.cs
@@ -1031,6 +1031,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance
RequestExtensions: config.RequestExtensions,
ExtensionSdkPath: config.ExtensionSdkPath,
ExtensionInfo: config.ExtensionInfo,
+ CanvasProvider: config.CanvasProvider,
Providers: config.Providers,
Models: config.Models,
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
@@ -1241,6 +1242,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes
RequestExtensions: config.RequestExtensions,
ExtensionSdkPath: config.ExtensionSdkPath,
ExtensionInfo: config.ExtensionInfo,
+ CanvasProvider: config.CanvasProvider,
OpenCanvases: config.OpenCanvases,
Providers: config.Providers,
Models: config.Models,
@@ -2483,6 +2485,7 @@ internal record CreateSessionRequest(
bool? RequestExtensions = null,
string? ExtensionSdkPath = null,
ExtensionInfo? ExtensionInfo = null,
+ CanvasProviderIdentity? CanvasProvider = null,
IList? Providers = null,
IList? Models = null,
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null,
@@ -2578,6 +2581,7 @@ internal record ResumeSessionRequest(
bool? RequestExtensions = null,
string? ExtensionSdkPath = null,
ExtensionInfo? ExtensionInfo = null,
+ CanvasProviderIdentity? CanvasProvider = null,
IList? OpenCanvases = null,
IList? Providers = null,
IList? Models = null,
diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs
index ecb277439..e6a66cc16 100644
--- a/dotnet/src/Types.cs
+++ b/dotnet/src/Types.cs
@@ -2810,6 +2810,7 @@ protected SessionConfigBase(SessionConfigBase? other)
RequestExtensions = other.RequestExtensions;
ExtensionSdkPath = other.ExtensionSdkPath;
ExtensionInfo = other.ExtensionInfo;
+ CanvasProvider = other.CanvasProvider;
CanvasHandler = other.CanvasHandler;
#pragma warning restore GHCP001
SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null;
@@ -3238,6 +3239,16 @@ protected SessionConfigBase(SessionConfigBase? other)
[Experimental(Diagnostics.Experimental)]
public ExtensionInfo? ExtensionInfo { get; set; }
+ ///
+ /// Stable identity for a host/SDK connection that supplies built-in
+ /// canvases. When set, the runtime uses
+ /// verbatim as the agent-facing canvas extension id, so canvases declared on
+ /// a control connection survive reconnect and CLI restart. Honored on
+ /// session create and resume.
+ ///
+ [Experimental(Diagnostics.Experimental)]
+ public CanvasProviderIdentity? CanvasProvider { get; set; }
+
///
/// Provider-side canvas lifecycle handler. The SDK routes inbound
/// canvas.open / canvas.close / canvas.action.invoke
@@ -3922,5 +3933,6 @@ public sealed class SystemMessageTransformRpcResponse
[JsonSerializable(typeof(CanvasProviderOpenResult))]
[JsonSerializable(typeof(CanvasHostContext))]
[JsonSerializable(typeof(ExtensionInfo))]
+[JsonSerializable(typeof(CanvasProviderIdentity))]
#pragma warning restore GHCP001
internal partial class TypesJsonContext : JsonSerializerContext;
diff --git a/dotnet/test/Unit/CanvasTests.cs b/dotnet/test/Unit/CanvasTests.cs
index 612814d1f..1ad77d5bf 100644
--- a/dotnet/test/Unit/CanvasTests.cs
+++ b/dotnet/test/Unit/CanvasTests.cs
@@ -313,6 +313,28 @@ public void ExtensionInfo_Serializes_SourceAndName()
Assert.Equal("demo", doc.RootElement.GetProperty("name").GetString());
}
+ [Fact]
+ public void CanvasProviderIdentity_Serializes_IdAndName()
+ {
+ var options = GetSerializerOptions();
+ var identity = new CanvasProviderIdentity { Id = "app:builtin:window-1", Name = "Built-in" };
+ var json = JsonSerializer.Serialize(identity, options);
+ using var doc = JsonDocument.Parse(json);
+ Assert.Equal("app:builtin:window-1", doc.RootElement.GetProperty("id").GetString());
+ Assert.Equal("Built-in", doc.RootElement.GetProperty("name").GetString());
+ }
+
+ [Fact]
+ public void CanvasProviderIdentity_OmitsNullName()
+ {
+ var options = GetSerializerOptions();
+ var identity = new CanvasProviderIdentity { Id = "app:builtin:window-1" };
+ var json = JsonSerializer.Serialize(identity, options);
+ using var doc = JsonDocument.Parse(json);
+ Assert.Equal("app:builtin:window-1", doc.RootElement.GetProperty("id").GetString());
+ Assert.False(doc.RootElement.TryGetProperty("name", out _));
+ }
+
[Fact]
public async Task CanvasHandlerBase_DefaultOnClose_Completes()
{
@@ -348,6 +370,7 @@ public void SessionConfig_Clone_CopiesCanvasFields()
RequestCanvasRenderer = true,
RequestExtensions = true,
ExtensionInfo = new ExtensionInfo { Source = "github-app", Name = "demo" },
+ CanvasProvider = new CanvasProviderIdentity { Id = "app:builtin:window-1", Name = "Built-in" },
CanvasHandler = handler
};
@@ -360,6 +383,8 @@ public void SessionConfig_Clone_CopiesCanvasFields()
Assert.True(clone.RequestExtensions);
Assert.NotNull(clone.ExtensionInfo);
Assert.Equal("github-app", clone.ExtensionInfo!.Source);
+ Assert.NotNull(clone.CanvasProvider);
+ Assert.Equal("app:builtin:window-1", clone.CanvasProvider!.Id);
Assert.Same(handler, clone.CanvasHandler);
// Mutating the clone's list does not affect the original.
@@ -376,6 +401,7 @@ public void ResumeSessionConfig_Clone_CopiesCanvasFields()
Canvases = new[] { new CanvasDeclaration { Id = "c1", DisplayName = "C", Description = "d" } },
RequestCanvasRenderer = true,
ExtensionInfo = new ExtensionInfo { Source = "s", Name = "n" },
+ CanvasProvider = new CanvasProviderIdentity { Id = "app:builtin:window-2" },
CanvasHandler = handler
};
@@ -385,6 +411,8 @@ public void ResumeSessionConfig_Clone_CopiesCanvasFields()
Assert.Single(clone.Canvases!);
Assert.True(clone.RequestCanvasRenderer);
Assert.NotNull(clone.ExtensionInfo);
+ Assert.NotNull(clone.CanvasProvider);
+ Assert.Equal("app:builtin:window-2", clone.CanvasProvider!.Id);
Assert.Same(handler, clone.CanvasHandler);
}
diff --git a/go/canvas.go b/go/canvas.go
index 375f7c6ee..e31598bb1 100644
--- a/go/canvas.go
+++ b/go/canvas.go
@@ -42,6 +42,24 @@ type ExtensionInfo struct {
Name string `json:"name"`
}
+// CanvasProviderIdentity is the stable identity for a host/SDK connection
+// that supplies built-in canvases.
+//
+// When set on session create or resume, the runtime uses ID verbatim as the
+// agent-facing canvas extension id, so host-provided canvases survive
+// reconnect and CLI restart.
+//
+// Experimental: CanvasProviderIdentity is part of an experimental
+// wire-protocol surface and may change or be removed in future SDK or CLI
+// releases.
+type CanvasProviderIdentity struct {
+ // ID is an opaque, stable provider id used verbatim as the canvas
+ // extension id.
+ ID string `json:"id"`
+ // Name is an optional display name surfaced as the canvas extension name.
+ Name *string `json:"name,omitempty"`
+}
+
// CanvasError is a structured error returned from canvas handlers.
//
// Wire envelope:
diff --git a/go/client.go b/go/client.go
index 9e2819047..2049a6169 100644
--- a/go/client.go
+++ b/go/client.go
@@ -724,6 +724,8 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
req.RemoteSession = config.RemoteSession
req.Cloud = config.Cloud
req.Canvases = config.Canvases
+ req.ExtensionInfo = config.ExtensionInfo
+ req.CanvasProvider = config.CanvasProvider
req.RequestCanvasRenderer = config.RequestCanvasRenderer
req.RequestExtensions = config.RequestExtensions
req.ExtensionSDKPath = config.ExtensionSDKPath
@@ -1077,6 +1079,8 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
req.RemoteSession = config.RemoteSession
req.Canvases = config.Canvases
req.OpenCanvases = config.OpenCanvases
+ req.ExtensionInfo = config.ExtensionInfo
+ req.CanvasProvider = config.CanvasProvider
req.RequestCanvasRenderer = config.RequestCanvasRenderer
req.RequestExtensions = config.RequestExtensions
req.ExtensionSDKPath = config.ExtensionSDKPath
diff --git a/go/client_test.go b/go/client_test.go
index c889ced8d..7fa3ee87e 100644
--- a/go/client_test.go
+++ b/go/client_test.go
@@ -250,6 +250,82 @@ func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) {
assertCapiEnableWebSocketResponses(t, <-resumeParams)
}
+func TestClient_ForwardsCanvasProviderToSessionRequests(t *testing.T) {
+ rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
+ t.Cleanup(server.Stop)
+ client := &Client{
+ client: rpcClient,
+ RPC: rpc.NewServerRPC(rpcClient),
+ sessions: make(map[string]*Session),
+ }
+
+ createParams := make(chan json.RawMessage, 1)
+ server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
+ createParams <- append(json.RawMessage(nil), params...)
+ sessionID := sessionIDFromParams(t, params)
+ return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil
+ })
+
+ _, err := client.CreateSession(t.Context(), &SessionConfig{
+ ExtensionInfo: &ExtensionInfo{Source: "github-app", Name: "counter-provider"},
+ CanvasProvider: &CanvasProviderIdentity{ID: "app:builtin:window-1", Name: String("Built-in")},
+ })
+ if err != nil {
+ t.Fatalf("CreateSession failed: %v", err)
+ }
+ assertCanvasProviderForwarded(t, <-createParams, "app:builtin:window-1", "Built-in", "counter-provider")
+
+ resumeParams := make(chan json.RawMessage, 1)
+ server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
+ resumeParams <- append(json.RawMessage(nil), params...)
+ return []byte(`{"sessionId":"resumed-canvas","workspacePath":"/workspace"}`), nil
+ })
+
+ _, err = client.ResumeSessionWithOptions(t.Context(), "resumed-canvas", &ResumeSessionConfig{
+ CanvasProvider: &CanvasProviderIdentity{ID: "app:builtin:window-1"},
+ })
+ if err != nil {
+ t.Fatalf("ResumeSessionWithOptions failed: %v", err)
+ }
+ assertCanvasProviderForwarded(t, <-resumeParams, "app:builtin:window-1", "", "")
+}
+
+// assertCanvasProviderForwarded checks the outbound params carry canvasProvider
+// with the expected id. A non-empty wantName asserts the name is present; an
+// empty wantName asserts the name key is omitted from the wire. A non-empty
+// wantExtensionName asserts extensionInfo.name is forwarded alongside it.
+func assertCanvasProviderForwarded(t *testing.T, params json.RawMessage, wantID, wantName, wantExtensionName string) {
+ t.Helper()
+
+ var decoded map[string]any
+ if err := json.Unmarshal(params, &decoded); err != nil {
+ t.Fatalf("failed to unmarshal request params: %v", err)
+ }
+ provider, ok := decoded["canvasProvider"].(map[string]any)
+ if !ok {
+ t.Fatalf("expected canvasProvider object in request params, got %T", decoded["canvasProvider"])
+ }
+ if provider["id"] != wantID {
+ t.Fatalf("expected canvasProvider.id=%q, got %v", wantID, provider["id"])
+ }
+ if wantName == "" {
+ if _, present := provider["name"]; present {
+ t.Fatalf("expected canvasProvider.name to be omitted, got %v", provider["name"])
+ }
+ } else if provider["name"] != wantName {
+ t.Fatalf("expected canvasProvider.name=%q, got %v", wantName, provider["name"])
+ }
+ if wantExtensionName != "" {
+ info, ok := decoded["extensionInfo"].(map[string]any)
+ if !ok {
+ t.Fatalf("expected extensionInfo object in request params, got %T", decoded["extensionInfo"])
+ }
+ if info["name"] != wantExtensionName {
+ t.Fatalf("expected extensionInfo.name=%q, got %v", wantExtensionName, info["name"])
+ }
+ }
+}
+
func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) {
t.Helper()
diff --git a/go/types.go b/go/types.go
index ffb8af12a..76a1c9162 100644
--- a/go/types.go
+++ b/go/types.go
@@ -1215,6 +1215,9 @@ type SessionConfig struct {
CanvasHandler CanvasHandler `json:"-"`
// ExtensionInfo identifies the stable extension providing this session's canvases.
ExtensionInfo *ExtensionInfo
+ // CanvasProvider is the stable identity for a host/SDK connection that
+ // supplies built-in canvases, so they survive reconnect and CLI restart.
+ CanvasProvider *CanvasProviderIdentity
// ExpAssignments injects ExP assignment ("flight") data for this session,
// in the same JSON shape the Copilot CLI fetches from the experimentation
// service (CopilotExpAssignmentResponse). When supplied, the runtime feeds
@@ -1625,6 +1628,9 @@ type ResumeSessionConfig struct {
CanvasHandler CanvasHandler `json:"-"`
// ExtensionInfo identifies the stable extension providing this session's canvases.
ExtensionInfo *ExtensionInfo
+ // CanvasProvider is the stable identity for a host/SDK connection that
+ // supplies built-in canvases. See SessionConfig.CanvasProvider.
+ CanvasProvider *CanvasProviderIdentity
// ExpAssignments injects ExP assignment ("flight") data on resume. See
// SessionConfig.ExpAssignments. Re-supply on resume so the runtime
// re-applies the assignments after a CLI process restart.
@@ -2082,6 +2088,7 @@ type createSessionRequest struct {
RequestExtensions *bool `json:"requestExtensions,omitempty"`
ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"`
ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"`
+ CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"`
ExpAssignments any `json:"expAssignments,omitempty"`
Traceparent string `json:"traceparent,omitempty"`
Tracestate string `json:"tracestate,omitempty"`
@@ -2167,6 +2174,7 @@ type resumeSessionRequest struct {
RequestExtensions *bool `json:"requestExtensions,omitempty"`
ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"`
ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"`
+ CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"`
ExpAssignments any `json:"expAssignments,omitempty"`
Traceparent string `json:"traceparent,omitempty"`
Tracestate string `json:"tracestate,omitempty"`
diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts
index 613985103..f0f988a5a 100644
--- a/nodejs/src/client.ts
+++ b/nodejs/src/client.ts
@@ -1398,6 +1398,7 @@ export class CopilotClient {
requestExtensions: config.requestExtensions,
extensionSdkPath: config.extensionSdkPath,
extensionInfo: config.extensionInfo,
+ canvasProvider: config.canvasProvider,
commands: config.commands?.map((cmd) => ({
name: cmd.name,
description: cmd.description,
@@ -1611,6 +1612,7 @@ export class CopilotClient {
requestExtensions: config.requestExtensions,
extensionSdkPath: config.extensionSdkPath,
extensionInfo: config.extensionInfo,
+ canvasProvider: config.canvasProvider,
commands: config.commands?.map((cmd) => ({
name: cmd.name,
description: cmd.description,
diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts
index eebf9add5..a18a2a834 100644
--- a/nodejs/src/index.ts
+++ b/nodejs/src/index.ts
@@ -51,6 +51,7 @@ export type {
CommandContext,
CommandDefinition,
CommandHandler,
+ CanvasProviderIdentity,
CloudSessionOptions,
CloudSessionRepository,
AutoModeSwitchHandler,
diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts
index 4adb35b25..47add232c 100644
--- a/nodejs/src/types.ts
+++ b/nodejs/src/types.ts
@@ -1695,6 +1695,23 @@ export interface ExtensionInfo {
name: string;
}
+/**
+ * Stable identity for a host/SDK connection that supplies built-in canvases.
+ *
+ * When set on session create or resume, the runtime uses {@link id} verbatim
+ * as the agent-facing canvas extension id, so canvases declared on a control
+ * connection survive stdio reconnect and CLI process restart instead of being
+ * re-keyed to a per-connection id. The id is opaque to the runtime; a
+ * per-window-stable value such as `app:builtin:` is recommended. An
+ * id beginning with `connection:` is reserved and ignored by the runtime.
+ */
+export interface CanvasProviderIdentity {
+ /** Opaque, stable provider id used verbatim as the canvas extension id. */
+ id: string;
+ /** Optional display name surfaced as the canvas extension name. */
+ name?: string;
+}
+
/**
* Provider-scoped options for the Copilot API (CAPI).
*
@@ -1839,6 +1856,14 @@ export interface SessionConfigBase {
*/
extensionInfo?: ExtensionInfo;
+ /**
+ * Stable identity for a host/SDK connection that supplies built-in
+ * canvases. When set, the runtime uses `id` verbatim as the agent-facing
+ * canvas extension id, so canvases declared on a control connection survive
+ * reconnect and CLI restart. Honored on session create and resume.
+ */
+ canvasProvider?: CanvasProviderIdentity;
+
/**
* Slash commands registered for this session.
* When the CLI has a TUI, each command appears as `/name` for the user to invoke.
diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts
index d02bafbbf..5fcee21b9 100644
--- a/nodejs/test/client.test.ts
+++ b/nodejs/test/client.test.ts
@@ -289,6 +289,7 @@ describe("CopilotClient", () => {
requestCanvasRenderer: true,
requestExtensions: true,
extensionInfo: { source: "github-app", name: "counter-provider" },
+ canvasProvider: { id: "app:builtin:window-1", name: "Built-in" },
});
const payload = spy.mock.calls.find(([method]) => method === "session.create")![1] as any;
@@ -306,6 +307,10 @@ describe("CopilotClient", () => {
source: "github-app",
name: "counter-provider",
});
+ expect(payload.canvasProvider).toEqual({
+ id: "app:builtin:window-1",
+ name: "Built-in",
+ });
});
it("forwards canvas declarations in session.resume", async () => {
@@ -333,6 +338,7 @@ describe("CopilotClient", () => {
requestCanvasRenderer: true,
requestExtensions: true,
extensionInfo: { source: "github-app", name: "counter-provider" },
+ canvasProvider: { id: "app:builtin:window-1" },
});
const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any;
@@ -343,6 +349,7 @@ describe("CopilotClient", () => {
source: "github-app",
name: "counter-provider",
});
+ expect(payload.canvasProvider).toEqual({ id: "app:builtin:window-1" });
expect(payload.openCanvasInstances).toBeUndefined();
});
diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py
index 51be3727a..29ba105b1 100644
--- a/python/copilot/__init__.py
+++ b/python/copilot/__init__.py
@@ -24,6 +24,7 @@
CanvasHostContext,
CanvasHostContextCapabilities,
CanvasJsonSchema,
+ CanvasProviderIdentity,
ExtensionInfo,
OpenCanvasInstance,
)
@@ -196,6 +197,7 @@
"CanvasHostContext",
"CanvasHostContextCapabilities",
"CanvasJsonSchema",
+ "CanvasProviderIdentity",
"CapiSessionOptions",
"ChildProcessRuntimeConnection",
"CloudSessionOptions",
diff --git a/python/copilot/canvas.py b/python/copilot/canvas.py
index ddbc8539a..9b8dec525 100644
--- a/python/copilot/canvas.py
+++ b/python/copilot/canvas.py
@@ -39,6 +39,7 @@
"CanvasHostContext",
"CanvasHostContextCapabilities",
"CanvasJsonSchema",
+ "CanvasProviderIdentity",
"ExtensionInfo",
"OpenCanvasInstance",
]
@@ -66,6 +67,33 @@ def to_dict(self) -> dict[str, Any]:
return {"source": self.source, "name": self.name}
+@dataclass
+class CanvasProviderIdentity:
+ """Stable identity for a host/SDK connection that supplies built-in canvases.
+
+ Lets a host advertise a stable canvas-provider extension id so host-provided
+ canvases restore across a cold session resume. Serializes to
+ ``{"id": ...}`` (with an optional ``"name"``) on the wire.
+
+ .. note::
+
+ **Experimental.** This type is part of an experimental wire-protocol
+ surface and may change or be removed in future SDK or CLI releases.
+ """
+
+ id: str
+ """Stable provider identifier, e.g. ``"app:builtin:window-1"``."""
+
+ name: str | None = None
+ """Optional human-readable provider name."""
+
+ def to_dict(self) -> dict[str, Any]:
+ result: dict[str, Any] = {"id": self.id}
+ if self.name is not None:
+ result["name"] = self.name
+ return result
+
+
@dataclass
class CanvasDeclaration:
"""Declarative metadata for a single canvas, sent on create/resume.
diff --git a/python/copilot/client.py b/python/copilot/client.py
index 7dade4440..ff259991c 100644
--- a/python/copilot/client.py
+++ b/python/copilot/client.py
@@ -58,6 +58,7 @@
from .canvas import (
CanvasDeclaration,
CanvasHandler,
+ CanvasProviderIdentity,
ExtensionInfo,
)
from .copilot_request_handler import CopilotRequestHandler, create_copilot_request_adapter
@@ -1711,6 +1712,7 @@ async def create_session(
request_extensions: bool | None = None,
extension_sdk_path: str | None = None,
extension_info: ExtensionInfo | None = None,
+ canvas_provider: CanvasProviderIdentity | None = None,
canvas_handler: CanvasHandler | None = None,
exp_assignments: dict[str, Any] | None = None,
) -> CopilotSession:
@@ -2098,6 +2100,8 @@ async def create_session(
payload["extensionSdkPath"] = extension_sdk_path
if extension_info is not None:
payload["extensionInfo"] = extension_info.to_dict()
+ if canvas_provider is not None:
+ payload["canvasProvider"] = canvas_provider.to_dict()
if not self._client:
raise RuntimeError("Client not connected")
@@ -2340,6 +2344,7 @@ async def resume_session(
request_extensions: bool | None = None,
extension_sdk_path: str | None = None,
extension_info: ExtensionInfo | None = None,
+ canvas_provider: CanvasProviderIdentity | None = None,
canvas_handler: CanvasHandler | None = None,
open_canvases: list[OpenCanvasInstance] | None = None,
exp_assignments: dict[str, Any] | None = None,
@@ -2699,6 +2704,8 @@ async def resume_session(
payload["extensionSdkPath"] = extension_sdk_path
if extension_info is not None:
payload["extensionInfo"] = extension_info.to_dict()
+ if canvas_provider is not None:
+ payload["canvasProvider"] = canvas_provider.to_dict()
if not self._client:
raise RuntimeError("Client not connected")
diff --git a/python/test_canvas.py b/python/test_canvas.py
index 8bcb79a23..684cef6b7 100644
--- a/python/test_canvas.py
+++ b/python/test_canvas.py
@@ -14,6 +14,7 @@
CanvasDeclaration,
CanvasError,
CanvasHandler,
+ CanvasProviderIdentity,
ExtensionInfo,
OpenCanvasInstance,
)
@@ -67,6 +68,16 @@ def test_extension_info_serializes():
assert info.to_dict() == {"source": "github-app", "name": "my-ext"}
+def test_canvas_provider_identity_serializes():
+ provider = CanvasProviderIdentity(id="app:builtin:window-1", name="Built-in")
+ assert provider.to_dict() == {"id": "app:builtin:window-1", "name": "Built-in"}
+
+
+def test_canvas_provider_identity_drops_optional_name():
+ provider = CanvasProviderIdentity(id="app:builtin:window-1")
+ assert provider.to_dict() == {"id": "app:builtin:window-1"}
+
+
def test_canvas_open_response_drops_none_fields():
assert CanvasProviderOpenResult().to_dict() == {}
assert CanvasProviderOpenResult(url="https://x", status="ok").to_dict() == {
diff --git a/python/test_client.py b/python/test_client.py
index db6703c3b..710e27c10 100644
--- a/python/test_client.py
+++ b/python/test_client.py
@@ -11,8 +11,10 @@
import pytest
from copilot import (
+ CanvasProviderIdentity,
CapiSessionOptions,
CopilotClient,
+ ExtensionInfo,
ModelBillingTokenPrices,
ModelBillingTokenPricesLongContext,
RuntimeConnection,
@@ -556,6 +558,49 @@ async def mock_request(method, params, **kwargs):
finally:
await client.force_stop()
+ @pytest.mark.asyncio
+ async def test_create_and_resume_session_forward_canvas_provider(self):
+ client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH))
+ await client.start()
+ try:
+ captured = {}
+
+ async def mock_request(method, params, **kwargs):
+ captured[method] = params
+ if method in ("session.create", "session.resume"):
+ result = {"sessionId": params.get("sessionId") or "session-1"}
+ callback = kwargs.get("on_response_inline")
+ if callback is not None:
+ callback(result)
+ return result
+ return {}
+
+ client._client.request = mock_request
+ session = await client.create_session(
+ on_permission_request=PermissionHandler.approve_all,
+ extension_info=ExtensionInfo(source="github-app", name="counter"),
+ canvas_provider=CanvasProviderIdentity(id="app:builtin:window-1", name="Built-in"),
+ )
+ await client.resume_session(
+ session.session_id,
+ on_permission_request=PermissionHandler.approve_all,
+ canvas_provider=CanvasProviderIdentity(id="app:builtin:window-1"),
+ )
+
+ assert captured["session.create"]["canvasProvider"] == {
+ "id": "app:builtin:window-1",
+ "name": "Built-in",
+ }
+ assert captured["session.create"]["extensionInfo"] == {
+ "source": "github-app",
+ "name": "counter",
+ }
+ assert captured["session.resume"]["canvasProvider"] == {
+ "id": "app:builtin:window-1",
+ }
+ finally:
+ await client.force_stop()
+
@pytest.mark.asyncio
async def test_create_and_resume_session_forward_capi_options(self):
client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH))
diff --git a/rust/src/types.rs b/rust/src/types.rs
index 895c2f710..93650fc43 100644
--- a/rust/src/types.rs
+++ b/rust/src/types.rs
@@ -916,6 +916,42 @@ impl ExtensionInfo {
}
}
+/// Stable identity for a host/SDK connection that supplies built-in canvases.
+///
+/// When set on session create or resume, the runtime uses [`id`] verbatim as
+/// the agent-facing canvas extension id, so canvases declared on a control
+/// connection survive stdio reconnect and CLI process restart instead of being
+/// re-keyed to a per-connection id. The id is opaque to the runtime; a
+/// per-window-stable value such as `app:builtin:` is recommended. An
+/// id beginning with `connection:` is reserved and ignored by the runtime.
+///
+/// [`id`]: CanvasProviderIdentity::id
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct CanvasProviderIdentity {
+ /// Opaque, stable provider id used verbatim as the canvas extension id.
+ pub id: String,
+ /// Optional display name surfaced as the canvas extension name.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub name: Option,
+}
+
+impl CanvasProviderIdentity {
+ /// Create a canvas provider identity from a stable opaque id.
+ pub fn new(id: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ name: None,
+ }
+ }
+
+ /// Set the optional display name surfaced as the canvas extension name.
+ pub fn with_name(mut self, name: impl Into) -> Self {
+ self.name = Some(name.into());
+ self
+ }
+}
+
/// Configuration for a single MCP server.
///
/// MCP (Model Context Protocol) servers expose external tools to the
@@ -1598,6 +1634,9 @@ pub struct SessionConfig {
pub extension_sdk_path: Option,
/// Stable extension identity for canvas/tool providers on this connection.
pub extension_info: Option,
+ /// Stable identity for a host/SDK connection that supplies built-in
+ /// canvases, so they survive reconnect and CLI restart.
+ pub canvas_provider: Option,
/// Allowlist of built-in tool names the agent may use.
pub available_tools: Option>,
/// Blocklist of built-in tool names the agent must not use.
@@ -1837,6 +1876,7 @@ impl std::fmt::Debug for SessionConfig {
.field("request_extensions", &self.request_extensions)
.field("extension_sdk_path", &self.extension_sdk_path)
.field("extension_info", &self.extension_info)
+ .field("canvas_provider", &self.canvas_provider)
.field("available_tools", &self.available_tools)
.field("excluded_tools", &self.excluded_tools)
.field("mcp_servers", &self.mcp_servers)
@@ -1955,6 +1995,7 @@ impl Default for SessionConfig {
request_extensions: None,
extension_sdk_path: None,
extension_info: None,
+ canvas_provider: None,
available_tools: None,
excluded_tools: None,
mcp_servers: None,
@@ -2100,6 +2141,7 @@ impl SessionConfig {
request_extensions: self.request_extensions,
extension_sdk_path: self.extension_sdk_path,
extension_info: self.extension_info,
+ canvas_provider: self.canvas_provider,
available_tools: self.available_tools,
excluded_tools: self.excluded_tools,
tool_filter_precedence: "excluded",
@@ -2370,6 +2412,13 @@ impl SessionConfig {
self
}
+ /// Set the canvas provider identity for this connection so host-supplied
+ /// canvases survive reconnect and CLI restart.
+ pub fn with_canvas_provider(mut self, canvas_provider: CanvasProviderIdentity) -> Self {
+ self.canvas_provider = Some(canvas_provider);
+ self
+ }
+
/// Set the allowlist of built-in tool names the agent may use.
pub fn with_available_tools(mut self, tools: I) -> Self
where
@@ -2737,6 +2786,9 @@ pub struct ResumeSessionConfig {
pub extension_sdk_path: Option,
/// Stable extension identity for canvas/tool providers on this connection.
pub extension_info: Option,
+ /// Stable identity for a host/SDK connection that supplies built-in
+ /// canvases, so they rehydrate against a stable extension id on resume.
+ pub canvas_provider: Option,
/// Allowlist of tool names the agent may use.
pub available_tools: Option>,
/// Blocklist of built-in tool names.
@@ -2915,6 +2967,7 @@ impl std::fmt::Debug for ResumeSessionConfig {
.field("request_extensions", &self.request_extensions)
.field("extension_sdk_path", &self.extension_sdk_path)
.field("extension_info", &self.extension_info)
+ .field("canvas_provider", &self.canvas_provider)
.field("available_tools", &self.available_tools)
.field("excluded_tools", &self.excluded_tools)
.field("mcp_servers", &self.mcp_servers)
@@ -3068,6 +3121,7 @@ impl ResumeSessionConfig {
request_extensions: self.request_extensions,
extension_sdk_path: self.extension_sdk_path,
extension_info: self.extension_info,
+ canvas_provider: self.canvas_provider,
available_tools: self.available_tools,
excluded_tools: self.excluded_tools,
tool_filter_precedence: "excluded",
@@ -3158,6 +3212,7 @@ impl ResumeSessionConfig {
request_extensions: None,
extension_sdk_path: None,
extension_info: None,
+ canvas_provider: None,
available_tools: None,
excluded_tools: None,
mcp_servers: None,
@@ -3400,6 +3455,13 @@ impl ResumeSessionConfig {
self
}
+ /// Set the canvas provider identity for this connection on resume so
+ /// host-supplied canvases rehydrate against a stable extension id.
+ pub fn with_canvas_provider(mut self, canvas_provider: CanvasProviderIdentity) -> Self {
+ self.canvas_provider = Some(canvas_provider);
+ self
+ }
+
/// Set the allowlist of tool names the agent may use.
pub fn with_available_tools(mut self, tools: I) -> Self
where
diff --git a/rust/src/wire.rs b/rust/src/wire.rs
index e6dad66d5..260262d6c 100644
--- a/rust/src/wire.rs
+++ b/rust/src/wire.rs
@@ -24,9 +24,10 @@ use crate::generated::api_types::{
};
use crate::generated::session_events::ReasoningSummary;
use crate::types::{
- CapiSessionOptions, CloudSessionOptions, CustomAgentConfig, DefaultAgentConfig, ExtensionInfo,
- InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, MemoryConfiguration,
- NamedProviderConfig, ProviderConfig, ProviderModelConfig, SessionId, SystemMessageConfig, Tool,
+ CanvasProviderIdentity, CapiSessionOptions, CloudSessionOptions, CustomAgentConfig,
+ DefaultAgentConfig, ExtensionInfo, InfiniteSessionConfig, LargeToolOutputConfig,
+ McpServerConfig, MemoryConfiguration, NamedProviderConfig, ProviderConfig, ProviderModelConfig,
+ SessionId, SystemMessageConfig, Tool,
};
/// Wire representation of a slash command (name + description only). The
@@ -73,6 +74,8 @@ pub(crate) struct SessionCreateWire {
#[serde(skip_serializing_if = "Option::is_none")]
pub extension_info: Option,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub canvas_provider: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub available_tools: Option>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_tools: Option>,
@@ -191,6 +194,8 @@ pub(crate) struct SessionResumeWire {
#[serde(skip_serializing_if = "Option::is_none")]
pub extension_info: Option,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub canvas_provider: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub available_tools: Option>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_tools: Option>,
diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs
index 31b0cc233..10321296a 100644
--- a/rust/tests/session_test.rs
+++ b/rust/tests/session_test.rs
@@ -18,10 +18,10 @@ use github_copilot_sdk::rpc::{
};
use github_copilot_sdk::session_events::{McpOauthRequiredData, ReasoningSummary};
use github_copilot_sdk::types::{
- CloudSessionOptions, CloudSessionRepository, CommandContext, CommandDefinition, CommandHandler,
- DeliveryMode, ElicitationRequest, ElicitationResult, ExitPlanModeData, ExtensionInfo,
- MessageOptions, RequestId, SessionConfig, SessionId, SetModelOptions, Tool, ToolInvocation,
- ToolResult,
+ CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, CommandContext,
+ CommandDefinition, CommandHandler, DeliveryMode, ElicitationRequest, ElicitationResult,
+ ExitPlanModeData, ExtensionInfo, MessageOptions, RequestId, SessionConfig, SessionId,
+ SetModelOptions, Tool, ToolInvocation, ToolResult,
};
use github_copilot_sdk::{Client, ContextTier, tool};
use serde_json::Value;
@@ -637,7 +637,11 @@ async fn create_session_sends_canvas_wire_fields() {
.with_canvases([test_canvas("counter")])
.with_request_canvas_renderer(true)
.with_request_extensions(true)
- .with_extension_info(ExtensionInfo::new("github-app", "counter-provider")),
+ .with_extension_info(ExtensionInfo::new("github-app", "counter-provider"))
+ .with_canvas_provider(
+ CanvasProviderIdentity::new("app:builtin:window-1")
+ .with_name("Built-in"),
+ ),
)
.await
.unwrap()
@@ -658,6 +662,11 @@ async fn create_session_sends_canvas_wire_fields() {
request["params"]["extensionInfo"]["name"],
"counter-provider"
);
+ assert_eq!(
+ request["params"]["canvasProvider"]["id"],
+ "app:builtin:window-1"
+ );
+ assert_eq!(request["params"]["canvasProvider"]["name"], "Built-in");
let id = request["id"].as_u64().unwrap();
let session_id = requested_session_id(&request).to_string();
@@ -2912,6 +2921,7 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() {
.with_request_canvas_renderer(true)
.with_request_extensions(true)
.with_extension_info(ExtensionInfo::new("github-app", "counter-provider"))
+ .with_canvas_provider(CanvasProviderIdentity::new("app:builtin:window-1"))
.with_open_canvases([OpenCanvasInstance {
instance_id: "counter-1".to_string(),
extension_id: "github-app:counter-provider".to_string(),
@@ -2936,6 +2946,14 @@ async fn resume_session_sends_canvas_fields_and_captures_open_canvases() {
request["params"]["extensionInfo"]["name"],
"counter-provider"
);
+ assert_eq!(
+ request["params"]["canvasProvider"]["id"],
+ "app:builtin:window-1"
+ );
+ assert!(
+ request["params"]["canvasProvider"].get("name").is_none(),
+ "name should be omitted from the wire when None, not serialized as null"
+ );
assert_eq!(
request["params"]["openCanvases"][0]["instanceId"],
"counter-1"