Skip to content
Open
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
26 changes: 26 additions & 0 deletions dotnet/src/Canvas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,32 @@ public sealed class ExtensionInfo
public string Name { get; set; } = string.Empty;
}

/// <summary>
/// Stable identity for a host/SDK connection that supplies built-in canvases.
/// </summary>
/// <remarks>
/// When set on session create or resume, the runtime uses <see cref="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 <c>app:builtin:&lt;windowId&gt;</c> is
/// recommended. An id beginning with <c>connection:</c> is reserved and ignored
/// by the runtime.
/// </remarks>
[Experimental(Diagnostics.Experimental)]
public sealed class CanvasProviderIdentity
{
/// <summary>
/// Opaque, stable provider id used verbatim as the canvas extension id.
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;

/// <summary>Optional display name surfaced as the canvas extension name.</summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
}

/// <summary>Structured exception returned from canvas handlers.</summary>
/// <remarks>
/// Throw this from <see cref="ICanvasHandler"/> implementations to surface a
Expand Down
4 changes: 4 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,7 @@ public async Task<CopilotSession> 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,
Expand Down Expand Up @@ -1241,6 +1242,7 @@ public async Task<CopilotSession> 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,
Expand Down Expand Up @@ -2483,6 +2485,7 @@ internal record CreateSessionRequest(
bool? RequestExtensions = null,
string? ExtensionSdkPath = null,
ExtensionInfo? ExtensionInfo = null,
CanvasProviderIdentity? CanvasProvider = null,
IList<NamedProviderConfig>? Providers = null,
IList<ProviderModelConfig>? Models = null,
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null,
Expand Down Expand Up @@ -2578,6 +2581,7 @@ internal record ResumeSessionRequest(
bool? RequestExtensions = null,
string? ExtensionSdkPath = null,
ExtensionInfo? ExtensionInfo = null,
CanvasProviderIdentity? CanvasProvider = null,
IList<OpenCanvasInstance>? OpenCanvases = null,
IList<NamedProviderConfig>? Providers = null,
IList<ProviderModelConfig>? Models = null,
Expand Down
12 changes: 12 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -3238,6 +3239,16 @@ protected SessionConfigBase(SessionConfigBase? other)
[Experimental(Diagnostics.Experimental)]
public ExtensionInfo? ExtensionInfo { get; set; }

/// <summary>
/// Stable identity for a host/SDK connection that supplies built-in
/// canvases. When set, the runtime uses <see cref="CanvasProviderIdentity.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.
/// </summary>
[Experimental(Diagnostics.Experimental)]
public CanvasProviderIdentity? CanvasProvider { get; set; }

/// <summary>
/// Provider-side canvas lifecycle handler. The SDK routes inbound
/// <c>canvas.open</c> / <c>canvas.close</c> / <c>canvas.action.invoke</c>
Expand Down Expand Up @@ -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;
28 changes: 28 additions & 0 deletions dotnet/test/Unit/CanvasTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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
};

Expand All @@ -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.
Expand All @@ -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
};

Expand All @@ -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);
}

Expand Down
18 changes: 18 additions & 0 deletions go/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
8 changes: 8 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down
2 changes: 2 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type {
CommandContext,
CommandDefinition,
CommandHandler,
CanvasProviderIdentity,
CloudSessionOptions,
CloudSessionRepository,
AutoModeSwitchHandler,
Expand Down
25 changes: 25 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<windowId>` 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).
*
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading