Skip to content

Commit f10d05a

Browse files
jmoseleyCopilot
andcommitted
Add session-level canvasProvider field to Rust, Node, and .NET SDKs
Hosts can now supply a stable canvas-provider identity { id, name? } on session.create and session.resume so host-provided canvases restore across cold resume. Serializes as canvasProvider on the wire, mirroring the existing extensionInfo field. Implements the runtime contract from github/copilot-agent-runtime#10519. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 91eaa4b commit f10d05a

11 files changed

Lines changed: 195 additions & 8 deletions

File tree

dotnet/src/Canvas.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,32 @@ public sealed class ExtensionInfo
5757
public string Name { get; set; } = string.Empty;
5858
}
5959

60+
/// <summary>
61+
/// Stable identity for a host/SDK connection that supplies built-in canvases.
62+
/// </summary>
63+
/// <remarks>
64+
/// When set on session create or resume, the runtime uses <see cref="Id"/>
65+
/// verbatim as the agent-facing canvas extension id, so canvases declared on a
66+
/// control connection survive stdio reconnect and CLI process restart instead
67+
/// of being re-keyed to a per-connection id. The id is opaque to the runtime; a
68+
/// per-window-stable value such as <c>app:builtin:&lt;windowId&gt;</c> is
69+
/// recommended. An id beginning with <c>connection:</c> is reserved and ignored
70+
/// by the runtime.
71+
/// </remarks>
72+
[Experimental(Diagnostics.Experimental)]
73+
public sealed class CanvasProviderIdentity
74+
{
75+
/// <summary>
76+
/// Opaque, stable provider id used verbatim as the canvas extension id.
77+
/// </summary>
78+
[JsonPropertyName("id")]
79+
public string Id { get; set; } = string.Empty;
80+
81+
/// <summary>Optional display name surfaced as the canvas extension name.</summary>
82+
[JsonPropertyName("name")]
83+
public string? Name { get; set; }
84+
}
85+
6086
/// <summary>Structured exception returned from canvas handlers.</summary>
6187
/// <remarks>
6288
/// Throw this from <see cref="ICanvasHandler"/> implementations to surface a

dotnet/src/Client.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
10311031
RequestExtensions: config.RequestExtensions,
10321032
ExtensionSdkPath: config.ExtensionSdkPath,
10331033
ExtensionInfo: config.ExtensionInfo,
1034+
CanvasProvider: config.CanvasProvider,
10341035
Providers: config.Providers,
10351036
Models: config.Models,
10361037
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
@@ -1241,6 +1242,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
12411242
RequestExtensions: config.RequestExtensions,
12421243
ExtensionSdkPath: config.ExtensionSdkPath,
12431244
ExtensionInfo: config.ExtensionInfo,
1245+
CanvasProvider: config.CanvasProvider,
12441246
OpenCanvases: config.OpenCanvases,
12451247
Providers: config.Providers,
12461248
Models: config.Models,
@@ -2483,6 +2485,7 @@ internal record CreateSessionRequest(
24832485
bool? RequestExtensions = null,
24842486
string? ExtensionSdkPath = null,
24852487
ExtensionInfo? ExtensionInfo = null,
2488+
CanvasProviderIdentity? CanvasProvider = null,
24862489
IList<NamedProviderConfig>? Providers = null,
24872490
IList<ProviderModelConfig>? Models = null,
24882491
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null,
@@ -2578,6 +2581,7 @@ internal record ResumeSessionRequest(
25782581
bool? RequestExtensions = null,
25792582
string? ExtensionSdkPath = null,
25802583
ExtensionInfo? ExtensionInfo = null,
2584+
CanvasProviderIdentity? CanvasProvider = null,
25812585
IList<OpenCanvasInstance>? OpenCanvases = null,
25822586
IList<NamedProviderConfig>? Providers = null,
25832587
IList<ProviderModelConfig>? Models = null,

dotnet/src/Types.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2810,6 +2810,7 @@ protected SessionConfigBase(SessionConfigBase? other)
28102810
RequestExtensions = other.RequestExtensions;
28112811
ExtensionSdkPath = other.ExtensionSdkPath;
28122812
ExtensionInfo = other.ExtensionInfo;
2813+
CanvasProvider = other.CanvasProvider;
28132814
CanvasHandler = other.CanvasHandler;
28142815
#pragma warning restore GHCP001
28152816
SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null;
@@ -3238,6 +3239,16 @@ protected SessionConfigBase(SessionConfigBase? other)
32383239
[Experimental(Diagnostics.Experimental)]
32393240
public ExtensionInfo? ExtensionInfo { get; set; }
32403241

3242+
/// <summary>
3243+
/// Stable identity for a host/SDK connection that supplies built-in
3244+
/// canvases. When set, the runtime uses <see cref="CanvasProviderIdentity.Id"/>
3245+
/// verbatim as the agent-facing canvas extension id, so canvases declared on
3246+
/// a control connection survive reconnect and CLI restart. Honored on
3247+
/// session create and resume.
3248+
/// </summary>
3249+
[Experimental(Diagnostics.Experimental)]
3250+
public CanvasProviderIdentity? CanvasProvider { get; set; }
3251+
32413252
/// <summary>
32423253
/// Provider-side canvas lifecycle handler. The SDK routes inbound
32433254
/// <c>canvas.open</c> / <c>canvas.close</c> / <c>canvas.action.invoke</c>
@@ -3922,5 +3933,6 @@ public sealed class SystemMessageTransformRpcResponse
39223933
[JsonSerializable(typeof(CanvasProviderOpenResult))]
39233934
[JsonSerializable(typeof(CanvasHostContext))]
39243935
[JsonSerializable(typeof(ExtensionInfo))]
3936+
[JsonSerializable(typeof(CanvasProviderIdentity))]
39253937
#pragma warning restore GHCP001
39263938
internal partial class TypesJsonContext : JsonSerializerContext;

dotnet/test/Unit/CanvasTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,28 @@ public void ExtensionInfo_Serializes_SourceAndName()
313313
Assert.Equal("demo", doc.RootElement.GetProperty("name").GetString());
314314
}
315315

316+
[Fact]
317+
public void CanvasProviderIdentity_Serializes_IdAndName()
318+
{
319+
var options = GetSerializerOptions();
320+
var identity = new CanvasProviderIdentity { Id = "app:builtin:window-1", Name = "Built-in" };
321+
var json = JsonSerializer.Serialize(identity, options);
322+
using var doc = JsonDocument.Parse(json);
323+
Assert.Equal("app:builtin:window-1", doc.RootElement.GetProperty("id").GetString());
324+
Assert.Equal("Built-in", doc.RootElement.GetProperty("name").GetString());
325+
}
326+
327+
[Fact]
328+
public void CanvasProviderIdentity_OmitsNullName()
329+
{
330+
var options = GetSerializerOptions();
331+
var identity = new CanvasProviderIdentity { Id = "app:builtin:window-1" };
332+
var json = JsonSerializer.Serialize(identity, options);
333+
using var doc = JsonDocument.Parse(json);
334+
Assert.Equal("app:builtin:window-1", doc.RootElement.GetProperty("id").GetString());
335+
Assert.False(doc.RootElement.TryGetProperty("name", out _));
336+
}
337+
316338
[Fact]
317339
public async Task CanvasHandlerBase_DefaultOnClose_Completes()
318340
{
@@ -348,6 +370,7 @@ public void SessionConfig_Clone_CopiesCanvasFields()
348370
RequestCanvasRenderer = true,
349371
RequestExtensions = true,
350372
ExtensionInfo = new ExtensionInfo { Source = "github-app", Name = "demo" },
373+
CanvasProvider = new CanvasProviderIdentity { Id = "app:builtin:window-1", Name = "Built-in" },
351374
CanvasHandler = handler
352375
};
353376

@@ -360,6 +383,8 @@ public void SessionConfig_Clone_CopiesCanvasFields()
360383
Assert.True(clone.RequestExtensions);
361384
Assert.NotNull(clone.ExtensionInfo);
362385
Assert.Equal("github-app", clone.ExtensionInfo!.Source);
386+
Assert.NotNull(clone.CanvasProvider);
387+
Assert.Equal("app:builtin:window-1", clone.CanvasProvider!.Id);
363388
Assert.Same(handler, clone.CanvasHandler);
364389

365390
// Mutating the clone's list does not affect the original.
@@ -376,6 +401,7 @@ public void ResumeSessionConfig_Clone_CopiesCanvasFields()
376401
Canvases = new[] { new CanvasDeclaration { Id = "c1", DisplayName = "C", Description = "d" } },
377402
RequestCanvasRenderer = true,
378403
ExtensionInfo = new ExtensionInfo { Source = "s", Name = "n" },
404+
CanvasProvider = new CanvasProviderIdentity { Id = "app:builtin:window-2" },
379405
CanvasHandler = handler
380406
};
381407

@@ -385,6 +411,8 @@ public void ResumeSessionConfig_Clone_CopiesCanvasFields()
385411
Assert.Single(clone.Canvases!);
386412
Assert.True(clone.RequestCanvasRenderer);
387413
Assert.NotNull(clone.ExtensionInfo);
414+
Assert.NotNull(clone.CanvasProvider);
415+
Assert.Equal("app:builtin:window-2", clone.CanvasProvider!.Id);
388416
Assert.Same(handler, clone.CanvasHandler);
389417
}
390418

nodejs/src/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1398,6 +1398,7 @@ export class CopilotClient {
13981398
requestExtensions: config.requestExtensions,
13991399
extensionSdkPath: config.extensionSdkPath,
14001400
extensionInfo: config.extensionInfo,
1401+
canvasProvider: config.canvasProvider,
14011402
commands: config.commands?.map((cmd) => ({
14021403
name: cmd.name,
14031404
description: cmd.description,
@@ -1611,6 +1612,7 @@ export class CopilotClient {
16111612
requestExtensions: config.requestExtensions,
16121613
extensionSdkPath: config.extensionSdkPath,
16131614
extensionInfo: config.extensionInfo,
1615+
canvasProvider: config.canvasProvider,
16141616
commands: config.commands?.map((cmd) => ({
16151617
name: cmd.name,
16161618
description: cmd.description,

nodejs/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export type {
5151
CommandContext,
5252
CommandDefinition,
5353
CommandHandler,
54+
CanvasProviderIdentity,
5455
CloudSessionOptions,
5556
CloudSessionRepository,
5657
AutoModeSwitchHandler,

nodejs/src/types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1695,6 +1695,23 @@ export interface ExtensionInfo {
16951695
name: string;
16961696
}
16971697

1698+
/**
1699+
* Stable identity for a host/SDK connection that supplies built-in canvases.
1700+
*
1701+
* When set on session create or resume, the runtime uses {@link id} verbatim
1702+
* as the agent-facing canvas extension id, so canvases declared on a control
1703+
* connection survive stdio reconnect and CLI process restart instead of being
1704+
* re-keyed to a per-connection id. The id is opaque to the runtime; a
1705+
* per-window-stable value such as `app:builtin:<windowId>` is recommended. An
1706+
* id beginning with `connection:` is reserved and ignored by the runtime.
1707+
*/
1708+
export interface CanvasProviderIdentity {
1709+
/** Opaque, stable provider id used verbatim as the canvas extension id. */
1710+
id: string;
1711+
/** Optional display name surfaced as the canvas extension name. */
1712+
name?: string;
1713+
}
1714+
16981715
/**
16991716
* Provider-scoped options for the Copilot API (CAPI).
17001717
*
@@ -1839,6 +1856,14 @@ export interface SessionConfigBase {
18391856
*/
18401857
extensionInfo?: ExtensionInfo;
18411858

1859+
/**
1860+
* Stable identity for a host/SDK connection that supplies built-in
1861+
* canvases. When set, the runtime uses `id` verbatim as the agent-facing
1862+
* canvas extension id, so canvases declared on a control connection survive
1863+
* reconnect and CLI restart. Honored on session create and resume.
1864+
*/
1865+
canvasProvider?: CanvasProviderIdentity;
1866+
18421867
/**
18431868
* Slash commands registered for this session.
18441869
* When the CLI has a TUI, each command appears as `/name` for the user to invoke.

nodejs/test/client.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ describe("CopilotClient", () => {
289289
requestCanvasRenderer: true,
290290
requestExtensions: true,
291291
extensionInfo: { source: "github-app", name: "counter-provider" },
292+
canvasProvider: { id: "app:builtin:window-1", name: "Built-in" },
292293
});
293294

294295
const payload = spy.mock.calls.find(([method]) => method === "session.create")![1] as any;
@@ -306,6 +307,10 @@ describe("CopilotClient", () => {
306307
source: "github-app",
307308
name: "counter-provider",
308309
});
310+
expect(payload.canvasProvider).toEqual({
311+
id: "app:builtin:window-1",
312+
name: "Built-in",
313+
});
309314
});
310315

311316
it("forwards canvas declarations in session.resume", async () => {
@@ -333,6 +338,7 @@ describe("CopilotClient", () => {
333338
requestCanvasRenderer: true,
334339
requestExtensions: true,
335340
extensionInfo: { source: "github-app", name: "counter-provider" },
341+
canvasProvider: { id: "app:builtin:window-1" },
336342
});
337343

338344
const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any;
@@ -343,6 +349,7 @@ describe("CopilotClient", () => {
343349
source: "github-app",
344350
name: "counter-provider",
345351
});
352+
expect(payload.canvasProvider).toEqual({ id: "app:builtin:window-1" });
346353
expect(payload.openCanvasInstances).toBeUndefined();
347354
});
348355

rust/src/types.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,42 @@ impl ExtensionInfo {
916916
}
917917
}
918918

919+
/// Stable identity for a host/SDK connection that supplies built-in canvases.
920+
///
921+
/// When set on session create or resume, the runtime uses [`id`] verbatim as
922+
/// the agent-facing canvas extension id, so canvases declared on a control
923+
/// connection survive stdio reconnect and CLI process restart instead of being
924+
/// re-keyed to a per-connection id. The id is opaque to the runtime; a
925+
/// per-window-stable value such as `app:builtin:<windowId>` is recommended. An
926+
/// id beginning with `connection:` is reserved and ignored by the runtime.
927+
///
928+
/// [`id`]: CanvasProviderIdentity::id
929+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
930+
#[serde(rename_all = "camelCase")]
931+
pub struct CanvasProviderIdentity {
932+
/// Opaque, stable provider id used verbatim as the canvas extension id.
933+
pub id: String,
934+
/// Optional display name surfaced as the canvas extension name.
935+
#[serde(skip_serializing_if = "Option::is_none")]
936+
pub name: Option<String>,
937+
}
938+
939+
impl CanvasProviderIdentity {
940+
/// Create a canvas provider identity from a stable opaque id.
941+
pub fn new(id: impl Into<String>) -> Self {
942+
Self {
943+
id: id.into(),
944+
name: None,
945+
}
946+
}
947+
948+
/// Set the optional display name surfaced as the canvas extension name.
949+
pub fn with_name(mut self, name: impl Into<String>) -> Self {
950+
self.name = Some(name.into());
951+
self
952+
}
953+
}
954+
919955
/// Configuration for a single MCP server.
920956
///
921957
/// MCP (Model Context Protocol) servers expose external tools to the
@@ -1598,6 +1634,9 @@ pub struct SessionConfig {
15981634
pub extension_sdk_path: Option<String>,
15991635
/// Stable extension identity for canvas/tool providers on this connection.
16001636
pub extension_info: Option<ExtensionInfo>,
1637+
/// Stable identity for a host/SDK connection that supplies built-in
1638+
/// canvases, so they survive reconnect and CLI restart.
1639+
pub canvas_provider: Option<CanvasProviderIdentity>,
16011640
/// Allowlist of built-in tool names the agent may use.
16021641
pub available_tools: Option<Vec<String>>,
16031642
/// Blocklist of built-in tool names the agent must not use.
@@ -1837,6 +1876,7 @@ impl std::fmt::Debug for SessionConfig {
18371876
.field("request_extensions", &self.request_extensions)
18381877
.field("extension_sdk_path", &self.extension_sdk_path)
18391878
.field("extension_info", &self.extension_info)
1879+
.field("canvas_provider", &self.canvas_provider)
18401880
.field("available_tools", &self.available_tools)
18411881
.field("excluded_tools", &self.excluded_tools)
18421882
.field("mcp_servers", &self.mcp_servers)
@@ -1955,6 +1995,7 @@ impl Default for SessionConfig {
19551995
request_extensions: None,
19561996
extension_sdk_path: None,
19571997
extension_info: None,
1998+
canvas_provider: None,
19581999
available_tools: None,
19592000
excluded_tools: None,
19602001
mcp_servers: None,
@@ -2100,6 +2141,7 @@ impl SessionConfig {
21002141
request_extensions: self.request_extensions,
21012142
extension_sdk_path: self.extension_sdk_path,
21022143
extension_info: self.extension_info,
2144+
canvas_provider: self.canvas_provider,
21032145
available_tools: self.available_tools,
21042146
excluded_tools: self.excluded_tools,
21052147
tool_filter_precedence: "excluded",
@@ -2370,6 +2412,13 @@ impl SessionConfig {
23702412
self
23712413
}
23722414

2415+
/// Set the canvas provider identity for this connection so host-supplied
2416+
/// canvases survive reconnect and CLI restart.
2417+
pub fn with_canvas_provider(mut self, canvas_provider: CanvasProviderIdentity) -> Self {
2418+
self.canvas_provider = Some(canvas_provider);
2419+
self
2420+
}
2421+
23732422
/// Set the allowlist of built-in tool names the agent may use.
23742423
pub fn with_available_tools<I, S>(mut self, tools: I) -> Self
23752424
where
@@ -2737,6 +2786,9 @@ pub struct ResumeSessionConfig {
27372786
pub extension_sdk_path: Option<String>,
27382787
/// Stable extension identity for canvas/tool providers on this connection.
27392788
pub extension_info: Option<ExtensionInfo>,
2789+
/// Stable identity for a host/SDK connection that supplies built-in
2790+
/// canvases, so they rehydrate against a stable extension id on resume.
2791+
pub canvas_provider: Option<CanvasProviderIdentity>,
27402792
/// Allowlist of tool names the agent may use.
27412793
pub available_tools: Option<Vec<String>>,
27422794
/// Blocklist of built-in tool names.
@@ -2915,6 +2967,7 @@ impl std::fmt::Debug for ResumeSessionConfig {
29152967
.field("request_extensions", &self.request_extensions)
29162968
.field("extension_sdk_path", &self.extension_sdk_path)
29172969
.field("extension_info", &self.extension_info)
2970+
.field("canvas_provider", &self.canvas_provider)
29182971
.field("available_tools", &self.available_tools)
29192972
.field("excluded_tools", &self.excluded_tools)
29202973
.field("mcp_servers", &self.mcp_servers)
@@ -3068,6 +3121,7 @@ impl ResumeSessionConfig {
30683121
request_extensions: self.request_extensions,
30693122
extension_sdk_path: self.extension_sdk_path,
30703123
extension_info: self.extension_info,
3124+
canvas_provider: self.canvas_provider,
30713125
available_tools: self.available_tools,
30723126
excluded_tools: self.excluded_tools,
30733127
tool_filter_precedence: "excluded",
@@ -3158,6 +3212,7 @@ impl ResumeSessionConfig {
31583212
request_extensions: None,
31593213
extension_sdk_path: None,
31603214
extension_info: None,
3215+
canvas_provider: None,
31613216
available_tools: None,
31623217
excluded_tools: None,
31633218
mcp_servers: None,
@@ -3400,6 +3455,13 @@ impl ResumeSessionConfig {
34003455
self
34013456
}
34023457

3458+
/// Set the canvas provider identity for this connection on resume so
3459+
/// host-supplied canvases rehydrate against a stable extension id.
3460+
pub fn with_canvas_provider(mut self, canvas_provider: CanvasProviderIdentity) -> Self {
3461+
self.canvas_provider = Some(canvas_provider);
3462+
self
3463+
}
3464+
34033465
/// Set the allowlist of tool names the agent may use.
34043466
pub fn with_available_tools<I, S>(mut self, tools: I) -> Self
34053467
where

0 commit comments

Comments
 (0)