Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
7 changes: 7 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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;
Expand All @@ -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();
});

Expand Down
62 changes: 62 additions & 0 deletions rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<windowId>` 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<String>,
}

impl CanvasProviderIdentity {
/// Create a canvas provider identity from a stable opaque id.
pub fn new(id: impl Into<String>) -> 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<String>) -> Self {
self.name = Some(name.into());
self
}
}

/// Configuration for a single MCP server.
///
/// MCP (Model Context Protocol) servers expose external tools to the
Expand Down Expand Up @@ -1598,6 +1634,9 @@ pub struct SessionConfig {
pub extension_sdk_path: Option<String>,
/// Stable extension identity for canvas/tool providers on this connection.
pub extension_info: Option<ExtensionInfo>,
/// Stable identity for a host/SDK connection that supplies built-in
/// canvases, so they survive reconnect and CLI restart.
pub canvas_provider: Option<CanvasProviderIdentity>,
/// Allowlist of built-in tool names the agent may use.
pub available_tools: Option<Vec<String>>,
/// Blocklist of built-in tool names the agent must not use.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<I, S>(mut self, tools: I) -> Self
where
Expand Down Expand Up @@ -2737,6 +2786,9 @@ pub struct ResumeSessionConfig {
pub extension_sdk_path: Option<String>,
/// Stable extension identity for canvas/tool providers on this connection.
pub extension_info: Option<ExtensionInfo>,
/// 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<CanvasProviderIdentity>,
/// Allowlist of tool names the agent may use.
pub available_tools: Option<Vec<String>>,
/// Blocklist of built-in tool names.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<I, S>(mut self, tools: I) -> Self
where
Expand Down
Loading
Loading