Skip to content

Commit 09b2a63

Browse files
jmoseleyCopilot
andauthored
Add session-level canvasProvider field to Rust, Node, .NET, Go, and Python SDKs (#1847)
* 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> * Assert canvasProvider.name key is absent, not just null Indexing with ["name"] returns Value::Null both when the key is absent and when it is present as null. Since the wire contract omits name when None, assert the key is not present to catch a regression that would serialize name: null. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add canvasProvider to Go and Python SDKs Extend the session-level canvasProvider field to the Go and Python SDKs for parity with Rust, Node, and .NET. Hosts can now send a stable canvas-provider identity ({ id, name? }) on session.create and session.resume so host-provided canvases restore across cold resume. Go: add CanvasProviderIdentity, wire it onto SessionConfig, ResumeSessionConfig, and both request structs, and forward it in client.go. Also fix a pre-existing gap where ExtensionInfo was declared but never forwarded on create/resume. Python: add CanvasProviderIdentity dataclass, export it, and forward a canvas_provider param on create_session/resume_session. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 522ff2c commit 09b2a63

20 files changed

Lines changed: 397 additions & 9 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
@@ -1143,6 +1143,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
11431143
RequestExtensions: config.RequestExtensions,
11441144
ExtensionSdkPath: config.ExtensionSdkPath,
11451145
ExtensionInfo: config.ExtensionInfo,
1146+
CanvasProvider: config.CanvasProvider,
11461147
Providers: config.Providers,
11471148
Models: config.Models,
11481149
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
@@ -1353,6 +1354,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
13531354
RequestExtensions: config.RequestExtensions,
13541355
ExtensionSdkPath: config.ExtensionSdkPath,
13551356
ExtensionInfo: config.ExtensionInfo,
1357+
CanvasProvider: config.CanvasProvider,
13561358
OpenCanvases: config.OpenCanvases,
13571359
Providers: config.Providers,
13581360
Models: config.Models,
@@ -2694,6 +2696,7 @@ internal record CreateSessionRequest(
26942696
bool? RequestExtensions = null,
26952697
string? ExtensionSdkPath = null,
26962698
ExtensionInfo? ExtensionInfo = null,
2699+
CanvasProviderIdentity? CanvasProvider = null,
26972700
IList<NamedProviderConfig>? Providers = null,
26982701
IList<ProviderModelConfig>? Models = null,
26992702
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null,
@@ -2794,6 +2797,7 @@ internal record ResumeSessionRequest(
27942797
bool? RequestExtensions = null,
27952798
string? ExtensionSdkPath = null,
27962799
ExtensionInfo? ExtensionInfo = null,
2800+
CanvasProviderIdentity? CanvasProvider = null,
27972801
IList<OpenCanvasInstance>? OpenCanvases = null,
27982802
IList<NamedProviderConfig>? Providers = null,
27992803
IList<ProviderModelConfig>? Models = null,

dotnet/src/Types.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2868,6 +2868,7 @@ protected SessionConfigBase(SessionConfigBase? other)
28682868
RequestExtensions = other.RequestExtensions;
28692869
ExtensionSdkPath = other.ExtensionSdkPath;
28702870
ExtensionInfo = other.ExtensionInfo;
2871+
CanvasProvider = other.CanvasProvider;
28712872
CanvasHandler = other.CanvasHandler;
28722873
#pragma warning restore GHCP001
28732874
SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null;
@@ -3337,6 +3338,16 @@ protected SessionConfigBase(SessionConfigBase? other)
33373338
[Experimental(Diagnostics.Experimental)]
33383339
public ExtensionInfo? ExtensionInfo { get; set; }
33393340

3341+
/// <summary>
3342+
/// Stable identity for a host/SDK connection that supplies built-in
3343+
/// canvases. When set, the runtime uses <see cref="CanvasProviderIdentity.Id"/>
3344+
/// verbatim as the agent-facing canvas extension id, so canvases declared on
3345+
/// a control connection survive reconnect and CLI restart. Honored on
3346+
/// session create and resume.
3347+
/// </summary>
3348+
[Experimental(Diagnostics.Experimental)]
3349+
public CanvasProviderIdentity? CanvasProvider { get; set; }
3350+
33403351
/// <summary>
33413352
/// Provider-side canvas lifecycle handler. The SDK routes inbound
33423353
/// <c>canvas.open</c> / <c>canvas.close</c> / <c>canvas.action.invoke</c>
@@ -4021,5 +4032,6 @@ public sealed class SystemMessageTransformRpcResponse
40214032
[JsonSerializable(typeof(CanvasProviderOpenResult))]
40224033
[JsonSerializable(typeof(CanvasHostContext))]
40234034
[JsonSerializable(typeof(ExtensionInfo))]
4035+
[JsonSerializable(typeof(CanvasProviderIdentity))]
40244036
#pragma warning restore GHCP001
40254037
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

go/canvas.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ type ExtensionInfo struct {
4242
Name string `json:"name"`
4343
}
4444

45+
// CanvasProviderIdentity is the stable identity for a host/SDK connection
46+
// that supplies built-in canvases.
47+
//
48+
// When set on session create or resume, the runtime uses ID verbatim as the
49+
// agent-facing canvas extension id, so host-provided canvases survive
50+
// reconnect and CLI restart.
51+
//
52+
// Experimental: CanvasProviderIdentity is part of an experimental
53+
// wire-protocol surface and may change or be removed in future SDK or CLI
54+
// releases.
55+
type CanvasProviderIdentity struct {
56+
// ID is an opaque, stable provider id used verbatim as the canvas
57+
// extension id.
58+
ID string `json:"id"`
59+
// Name is an optional display name surfaced as the canvas extension name.
60+
Name *string `json:"name,omitempty"`
61+
}
62+
4563
// CanvasError is a structured error returned from canvas handlers.
4664
//
4765
// Wire envelope:

go/client.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,8 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
727727
req.RemoteSession = config.RemoteSession
728728
req.Cloud = config.Cloud
729729
req.Canvases = config.Canvases
730+
req.ExtensionInfo = config.ExtensionInfo
731+
req.CanvasProvider = config.CanvasProvider
730732
req.RequestCanvasRenderer = config.RequestCanvasRenderer
731733
req.RequestExtensions = config.RequestExtensions
732734
req.ExtensionSDKPath = config.ExtensionSDKPath
@@ -1091,6 +1093,8 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
10911093
req.RemoteSession = config.RemoteSession
10921094
req.Canvases = config.Canvases
10931095
req.OpenCanvases = config.OpenCanvases
1096+
req.ExtensionInfo = config.ExtensionInfo
1097+
req.CanvasProvider = config.CanvasProvider
10941098
req.RequestCanvasRenderer = config.RequestCanvasRenderer
10951099
req.RequestExtensions = config.RequestExtensions
10961100
req.ExtensionSDKPath = config.ExtensionSDKPath

go/client_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,82 @@ func TestClient_ForwardsCapiOptionsToSessionRequests(t *testing.T) {
251251
assertCapiEnableWebSocketResponses(t, <-resumeParams)
252252
}
253253

254+
func TestClient_ForwardsCanvasProviderToSessionRequests(t *testing.T) {
255+
rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
256+
t.Cleanup(server.Stop)
257+
client := &Client{
258+
client: rpcClient,
259+
RPC: rpc.NewServerRPC(rpcClient),
260+
sessions: make(map[string]*Session),
261+
}
262+
263+
createParams := make(chan json.RawMessage, 1)
264+
server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
265+
createParams <- append(json.RawMessage(nil), params...)
266+
sessionID := sessionIDFromParams(t, params)
267+
return []byte(`{"sessionId":"` + sessionID + `","workspacePath":"/workspace"}`), nil
268+
})
269+
270+
_, err := client.CreateSession(t.Context(), &SessionConfig{
271+
ExtensionInfo: &ExtensionInfo{Source: "github-app", Name: "counter-provider"},
272+
CanvasProvider: &CanvasProviderIdentity{ID: "app:builtin:window-1", Name: String("Built-in")},
273+
})
274+
if err != nil {
275+
t.Fatalf("CreateSession failed: %v", err)
276+
}
277+
assertCanvasProviderForwarded(t, <-createParams, "app:builtin:window-1", "Built-in", "counter-provider")
278+
279+
resumeParams := make(chan json.RawMessage, 1)
280+
server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
281+
resumeParams <- append(json.RawMessage(nil), params...)
282+
return []byte(`{"sessionId":"resumed-canvas","workspacePath":"/workspace"}`), nil
283+
})
284+
285+
_, err = client.ResumeSessionWithOptions(t.Context(), "resumed-canvas", &ResumeSessionConfig{
286+
CanvasProvider: &CanvasProviderIdentity{ID: "app:builtin:window-1"},
287+
})
288+
if err != nil {
289+
t.Fatalf("ResumeSessionWithOptions failed: %v", err)
290+
}
291+
assertCanvasProviderForwarded(t, <-resumeParams, "app:builtin:window-1", "", "")
292+
}
293+
294+
// assertCanvasProviderForwarded checks the outbound params carry canvasProvider
295+
// with the expected id. A non-empty wantName asserts the name is present; an
296+
// empty wantName asserts the name key is omitted from the wire. A non-empty
297+
// wantExtensionName asserts extensionInfo.name is forwarded alongside it.
298+
func assertCanvasProviderForwarded(t *testing.T, params json.RawMessage, wantID, wantName, wantExtensionName string) {
299+
t.Helper()
300+
301+
var decoded map[string]any
302+
if err := json.Unmarshal(params, &decoded); err != nil {
303+
t.Fatalf("failed to unmarshal request params: %v", err)
304+
}
305+
provider, ok := decoded["canvasProvider"].(map[string]any)
306+
if !ok {
307+
t.Fatalf("expected canvasProvider object in request params, got %T", decoded["canvasProvider"])
308+
}
309+
if provider["id"] != wantID {
310+
t.Fatalf("expected canvasProvider.id=%q, got %v", wantID, provider["id"])
311+
}
312+
if wantName == "" {
313+
if _, present := provider["name"]; present {
314+
t.Fatalf("expected canvasProvider.name to be omitted, got %v", provider["name"])
315+
}
316+
} else if provider["name"] != wantName {
317+
t.Fatalf("expected canvasProvider.name=%q, got %v", wantName, provider["name"])
318+
}
319+
if wantExtensionName != "" {
320+
info, ok := decoded["extensionInfo"].(map[string]any)
321+
if !ok {
322+
t.Fatalf("expected extensionInfo object in request params, got %T", decoded["extensionInfo"])
323+
}
324+
if info["name"] != wantExtensionName {
325+
t.Fatalf("expected extensionInfo.name=%q, got %v", wantExtensionName, info["name"])
326+
}
327+
}
328+
}
329+
254330
func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) {
255331
rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
256332
t.Cleanup(server.Stop)

go/types.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,9 @@ type SessionConfig struct {
12351235
CanvasHandler CanvasHandler `json:"-"`
12361236
// ExtensionInfo identifies the stable extension providing this session's canvases.
12371237
ExtensionInfo *ExtensionInfo
1238+
// CanvasProvider is the stable identity for a host/SDK connection that
1239+
// supplies built-in canvases, so they survive reconnect and CLI restart.
1240+
CanvasProvider *CanvasProviderIdentity
12381241
// ExpAssignments injects ExP assignment ("flight") data for this session,
12391242
// in the same JSON shape the Copilot CLI fetches from the experimentation
12401243
// service (CopilotExpAssignmentResponse). When supplied, the runtime feeds
@@ -1666,6 +1669,9 @@ type ResumeSessionConfig struct {
16661669
CanvasHandler CanvasHandler `json:"-"`
16671670
// ExtensionInfo identifies the stable extension providing this session's canvases.
16681671
ExtensionInfo *ExtensionInfo
1672+
// CanvasProvider is the stable identity for a host/SDK connection that
1673+
// supplies built-in canvases. See SessionConfig.CanvasProvider.
1674+
CanvasProvider *CanvasProviderIdentity
16691675
// ExpAssignments injects ExP assignment ("flight") data on resume. See
16701676
// SessionConfig.ExpAssignments. Re-supply on resume so the runtime
16711677
// re-applies the assignments after a CLI process restart.
@@ -2131,6 +2137,7 @@ type createSessionRequest struct {
21312137
RequestExtensions *bool `json:"requestExtensions,omitempty"`
21322138
ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"`
21332139
ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"`
2140+
CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"`
21342141
ExpAssignments any `json:"expAssignments,omitempty"`
21352142
EnableManagedSettings *bool `json:"enableManagedSettings,omitempty"`
21362143
Traceparent string `json:"traceparent,omitempty"`
@@ -2221,6 +2228,7 @@ type resumeSessionRequest struct {
22212228
RequestExtensions *bool `json:"requestExtensions,omitempty"`
22222229
ExtensionSDKPath *string `json:"extensionSdkPath,omitempty"`
22232230
ExtensionInfo *ExtensionInfo `json:"extensionInfo,omitempty"`
2231+
CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"`
22242232
ExpAssignments any `json:"expAssignments,omitempty"`
22252233
EnableManagedSettings *bool `json:"enableManagedSettings,omitempty"`
22262234
Traceparent string `json:"traceparent,omitempty"`

nodejs/src/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,7 @@ export class CopilotClient {
14151415
requestExtensions: config.requestExtensions,
14161416
extensionSdkPath: config.extensionSdkPath,
14171417
extensionInfo: config.extensionInfo,
1418+
canvasProvider: config.canvasProvider,
14181419
commands: config.commands?.map((cmd) => ({
14191420
name: cmd.name,
14201421
description: cmd.description,
@@ -1632,6 +1633,7 @@ export class CopilotClient {
16321633
requestExtensions: config.requestExtensions,
16331634
extensionSdkPath: config.extensionSdkPath,
16341635
extensionInfo: config.extensionInfo,
1636+
canvasProvider: config.canvasProvider,
16351637
commands: config.commands?.map((cmd) => ({
16361638
name: cmd.name,
16371639
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,

0 commit comments

Comments
 (0)